diff --git a/packages/cli/generation/ir-generator/build.cjs b/packages/cli/generation/ir-generator/build.cjs index f2414affda0..d9b380ed7ac 100644 --- a/packages/cli/generation/ir-generator/build.cjs +++ b/packages/cli/generation/ir-generator/build.cjs @@ -6,27 +6,23 @@ const path = require("path"); main(); async function main() { - tsup.build({ + tsup.build({ entry: ['src/**/*.ts', '!src/__test__'], format: ['cjs'], clean: true, - // minify: true, + minify: true, dts: true, outDir: 'dist', target: "es2017", - external: [ - // Exclude the optional dependencies that aren't supported in the browser. - 'prettier', - ], tsconfig: "./build.tsconfig.json" }); process.chdir(path.join(__dirname, "dist")); // The module expects the imports defined in the index.d.ts file. - rename("index.d.cts", "index.d.ts"); + rename("index.d.cts", "index.d.ts"); - writeFile( + writeFile( "package.json", JSON.stringify( { diff --git a/packages/snippets/core/build.cjs b/packages/snippets/core/build.cjs index 5a53a914833..a65f9dac03d 100644 --- a/packages/snippets/core/build.cjs +++ b/packages/snippets/core/build.cjs @@ -14,10 +14,6 @@ async function main() { dts: true, outDir: 'dist', target: "es2017", - external: [ - // Exclude the optional dependencies that aren't supported in the browser. - 'prettier' - ], tsconfig: "./build.tsconfig.json" }); diff --git a/packages/snippets/core/src/__test__/generateDynamicIR.test.ts b/packages/snippets/core/src/__test__/generateDynamicIR.test.ts index 416e3188a49..9164f23b60a 100644 --- a/packages/snippets/core/src/__test__/generateDynamicIR.test.ts +++ b/packages/snippets/core/src/__test__/generateDynamicIR.test.ts @@ -30,13 +30,39 @@ describe("generateDynamicIR", () => { } } } + }, + "/filtered": { + get: { + summary: "This endpoint should be filtered out", + operationId: "filtered", + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string" } + } + } + } + } + } + } + } } } }; - const ir = await generateDynamicIR({ + const ir = generateDynamicIR({ spec: { type: "openapi", - openapi + openapi, + settings: { + filter: { + endpoints: ["GET /testdata"] + } + } }, language: "go" }); diff --git a/packages/snippets/core/src/utils/convertSpecToWorkspace.ts b/packages/snippets/core/src/utils/convertSpecToWorkspace.ts index 4ae9ad376ea..99fc12363ea 100644 --- a/packages/snippets/core/src/utils/convertSpecToWorkspace.ts +++ b/packages/snippets/core/src/utils/convertSpecToWorkspace.ts @@ -18,7 +18,8 @@ export function convertSpecToWorkspace({ const openapi = new OpenAPIWorkspace({ spec: { parsed: spec.openapi, - overrides: spec.overrides + overrides: spec.overrides, + settings: spec.settings }, generatorsConfiguration }); diff --git a/seed/csharp-model/inline-types/src/SeedObject.sln b/seed/csharp-model/inline-types/src/SeedObject.sln index eb1c1d2e48a..b97dbe09dea 100644 --- a/seed/csharp-model/inline-types/src/SeedObject.sln +++ b/seed/csharp-model/inline-types/src/SeedObject.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeedObject", "SeedObject\SeedObject.csproj", "{958A1B45-564B-4ACA-98CA-556D113065C0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeedObject", "SeedObject\SeedObject.csproj", "{AD1F9DE6-0EEA-4C0F-A203-D87741677FCA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeedObject.Test", "SeedObject.Test\SeedObject.Test.csproj", "{1474BFFD-3CFD-49A5-8CAE-C3A2B36C750A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeedObject.Test", "SeedObject.Test\SeedObject.Test.csproj", "{ADFBEAB6-3110-4762-9E42-1C3308776ED7}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -16,13 +16,13 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {958A1B45-564B-4ACA-98CA-556D113065C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {958A1B45-564B-4ACA-98CA-556D113065C0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {958A1B45-564B-4ACA-98CA-556D113065C0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {958A1B45-564B-4ACA-98CA-556D113065C0}.Release|Any CPU.Build.0 = Release|Any CPU - {1474BFFD-3CFD-49A5-8CAE-C3A2B36C750A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1474BFFD-3CFD-49A5-8CAE-C3A2B36C750A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1474BFFD-3CFD-49A5-8CAE-C3A2B36C750A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1474BFFD-3CFD-49A5-8CAE-C3A2B36C750A}.Release|Any CPU.Build.0 = Release|Any CPU + {AD1F9DE6-0EEA-4C0F-A203-D87741677FCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD1F9DE6-0EEA-4C0F-A203-D87741677FCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD1F9DE6-0EEA-4C0F-A203-D87741677FCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD1F9DE6-0EEA-4C0F-A203-D87741677FCA}.Release|Any CPU.Build.0 = Release|Any CPU + {ADFBEAB6-3110-4762-9E42-1C3308776ED7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADFBEAB6-3110-4762-9E42-1C3308776ED7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADFBEAB6-3110-4762-9E42-1C3308776ED7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADFBEAB6-3110-4762-9E42-1C3308776ED7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/seed/csharp-sdk/inline-types/src/SeedObject.sln b/seed/csharp-sdk/inline-types/src/SeedObject.sln index a0c9d232c76..04f8293ffea 100644 --- a/seed/csharp-sdk/inline-types/src/SeedObject.sln +++ b/seed/csharp-sdk/inline-types/src/SeedObject.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeedObject", "SeedObject\SeedObject.csproj", "{8E68FB11-2363-4ADA-9859-12868ECD8DED}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeedObject", "SeedObject\SeedObject.csproj", "{5F954A6C-8849-40D3-8109-615573D3A579}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeedObject.Test", "SeedObject.Test\SeedObject.Test.csproj", "{F6BA232C-3EE4-438E-AECC-8716B828D4BB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeedObject.Test", "SeedObject.Test\SeedObject.Test.csproj", "{3D1EA2A5-21F8-41B3-8AFA-F4C664F4CEC8}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -16,13 +16,13 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {8E68FB11-2363-4ADA-9859-12868ECD8DED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8E68FB11-2363-4ADA-9859-12868ECD8DED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8E68FB11-2363-4ADA-9859-12868ECD8DED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8E68FB11-2363-4ADA-9859-12868ECD8DED}.Release|Any CPU.Build.0 = Release|Any CPU - {F6BA232C-3EE4-438E-AECC-8716B828D4BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F6BA232C-3EE4-438E-AECC-8716B828D4BB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F6BA232C-3EE4-438E-AECC-8716B828D4BB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F6BA232C-3EE4-438E-AECC-8716B828D4BB}.Release|Any CPU.Build.0 = Release|Any CPU + {5F954A6C-8849-40D3-8109-615573D3A579}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F954A6C-8849-40D3-8109-615573D3A579}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F954A6C-8849-40D3-8109-615573D3A579}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F954A6C-8849-40D3-8109-615573D3A579}.Release|Any CPU.Build.0 = Release|Any CPU + {3D1EA2A5-21F8-41B3-8AFA-F4C664F4CEC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D1EA2A5-21F8-41B3-8AFA-F4C664F4CEC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D1EA2A5-21F8-41B3-8AFA-F4C664F4CEC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D1EA2A5-21F8-41B3-8AFA-F4C664F4CEC8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/seed/fastapi/inline-types/.mock/definition/__package__.yml b/seed/fastapi/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/fastapi/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/fastapi/inline-types/.mock/definition/api.yml b/seed/fastapi/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/fastapi/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/fastapi/inline-types/.mock/fern.config.json b/seed/fastapi/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/fastapi/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/fastapi/inline-types/.mock/generators.yml b/seed/fastapi/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/fastapi/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/fastapi/inline-types/__init__.py b/seed/fastapi/inline-types/__init__.py new file mode 100644 index 00000000000..559e53306bb --- /dev/null +++ b/seed/fastapi/inline-types/__init__.py @@ -0,0 +1,23 @@ +# This file was auto-generated by Fern from our API Definition. + +from .service import PostRootRequest +from .types import ( + InlineEnum, + InlineType1, + InlineType2, + InlinedDiscriminatedUnion1, + InlinedUndiscriminatedUnion1, + NestedInlineType1, + RootType1, +) + +__all__ = [ + "InlineEnum", + "InlineType1", + "InlineType2", + "InlinedDiscriminatedUnion1", + "InlinedUndiscriminatedUnion1", + "NestedInlineType1", + "PostRootRequest", + "RootType1", +] diff --git a/seed/fastapi/inline-types/core/__init__.py b/seed/fastapi/inline-types/core/__init__.py new file mode 100644 index 00000000000..f9c8e44aea0 --- /dev/null +++ b/seed/fastapi/inline-types/core/__init__.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +from .datetime_utils import serialize_datetime +from .exceptions import ( + FernHTTPException, + UnauthorizedException, + default_exception_handler, + fern_http_exception_handler, + http_exception_handler, +) +from .pydantic_utilities import ( + IS_PYDANTIC_V2, + UniversalBaseModel, + UniversalRootModel, + parse_obj_as, + universal_field_validator, + universal_root_validator, + update_forward_refs, +) +from .route_args import route_args +from .security import BearerToken +from .serialization import FieldMetadata, convert_and_respect_annotation_metadata + +__all__ = [ + "BearerToken", + "FernHTTPException", + "FieldMetadata", + "IS_PYDANTIC_V2", + "UnauthorizedException", + "UniversalBaseModel", + "UniversalRootModel", + "convert_and_respect_annotation_metadata", + "default_exception_handler", + "fern_http_exception_handler", + "http_exception_handler", + "parse_obj_as", + "route_args", + "serialize_datetime", + "universal_field_validator", + "universal_root_validator", + "update_forward_refs", +] diff --git a/seed/fastapi/inline-types/core/abstract_fern_service.py b/seed/fastapi/inline-types/core/abstract_fern_service.py new file mode 100644 index 00000000000..9966b4876da --- /dev/null +++ b/seed/fastapi/inline-types/core/abstract_fern_service.py @@ -0,0 +1,10 @@ +# This file was auto-generated by Fern from our API Definition. + +import abc + +import fastapi + + +class AbstractFernService(abc.ABC): + @classmethod + def _init_fern(cls, router: fastapi.APIRouter) -> None: ... diff --git a/seed/fastapi/inline-types/core/datetime_utils.py b/seed/fastapi/inline-types/core/datetime_utils.py new file mode 100644 index 00000000000..47344e9d9cc --- /dev/null +++ b/seed/fastapi/inline-types/core/datetime_utils.py @@ -0,0 +1,30 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt + + +def serialize_datetime(v: dt.datetime) -> str: + """ + Serialize a datetime including timezone info. + + Uses the timezone info provided if present, otherwise uses the current runtime's timezone info. + + UTC datetimes end in "Z" while all other timezones are represented as offset from UTC, e.g. +05:00. + """ + + def _serialize_zoned_datetime(v: dt.datetime) -> str: + if v.tzinfo is not None and v.tzinfo.tzname(None) == dt.timezone.utc.tzname( + None + ): + # UTC is a special case where we use "Z" at the end instead of "+00:00" + return v.isoformat().replace("+00:00", "Z") + else: + # Delegate to the typical +/- offset format + return v.isoformat() + + if v.tzinfo is not None: + return _serialize_zoned_datetime(v) + else: + local_tz = dt.datetime.now().astimezone().tzinfo + localized_dt = v.replace(tzinfo=local_tz) + return _serialize_zoned_datetime(localized_dt) diff --git a/seed/fastapi/inline-types/core/exceptions/__init__.py b/seed/fastapi/inline-types/core/exceptions/__init__.py new file mode 100644 index 00000000000..dae4b8980c1 --- /dev/null +++ b/seed/fastapi/inline-types/core/exceptions/__init__.py @@ -0,0 +1,17 @@ +# This file was auto-generated by Fern from our API Definition. + +from .fern_http_exception import FernHTTPException +from .handlers import ( + default_exception_handler, + fern_http_exception_handler, + http_exception_handler, +) +from .unauthorized import UnauthorizedException + +__all__ = [ + "FernHTTPException", + "UnauthorizedException", + "default_exception_handler", + "fern_http_exception_handler", + "http_exception_handler", +] diff --git a/seed/fastapi/inline-types/core/exceptions/fern_http_exception.py b/seed/fastapi/inline-types/core/exceptions/fern_http_exception.py new file mode 100644 index 00000000000..81610359a7f --- /dev/null +++ b/seed/fastapi/inline-types/core/exceptions/fern_http_exception.py @@ -0,0 +1,24 @@ +# This file was auto-generated by Fern from our API Definition. + +import abc +import fastapi +import typing + + +class FernHTTPException(abc.ABC, fastapi.HTTPException): + def __init__( + self, + status_code: int, + name: typing.Optional[str] = None, + content: typing.Optional[typing.Any] = None, + ): + super().__init__(status_code=status_code) + self.name = name + self.status_code = status_code + self.content = content + + def to_json_response(self) -> fastapi.responses.JSONResponse: + content = fastapi.encoders.jsonable_encoder(self.content, exclude_none=True) + return fastapi.responses.JSONResponse( + content=content, status_code=self.status_code + ) diff --git a/seed/fastapi/inline-types/core/exceptions/handlers.py b/seed/fastapi/inline-types/core/exceptions/handlers.py new file mode 100644 index 00000000000..ae1c2741f06 --- /dev/null +++ b/seed/fastapi/inline-types/core/exceptions/handlers.py @@ -0,0 +1,50 @@ +# This file was auto-generated by Fern from our API Definition. + +import logging + +import starlette +import starlette.exceptions + +import fastapi + +from .fern_http_exception import FernHTTPException + + +def fern_http_exception_handler( + request: fastapi.requests.Request, + exc: FernHTTPException, + skip_log: bool = False, +) -> fastapi.responses.JSONResponse: + if not skip_log: + logging.getLogger(__name__).error( + f"{exc.__class__.__name__} in {request.url.path}", exc_info=exc + ) + return exc.to_json_response() + + +def http_exception_handler( + request: fastapi.requests.Request, + exc: starlette.exceptions.HTTPException, + skip_log: bool = False, +) -> fastapi.responses.JSONResponse: + if not skip_log: + logging.getLogger(__name__).error( + f"{exc.__class__.__name__} in {request.url.path}", exc_info=exc + ) + return FernHTTPException( + status_code=exc.status_code, content=exc.detail + ).to_json_response() + + +def default_exception_handler( + request: fastapi.requests.Request, + exc: Exception, + skip_log: bool = False, +) -> fastapi.responses.JSONResponse: + if not skip_log: + logging.getLogger(__name__).error( + f"{exc.__class__.__name__} in {request.url.path}", exc_info=exc + ) + return FernHTTPException( + status_code=500, content="Internal Server Error" + ).to_json_response() diff --git a/seed/fastapi/inline-types/core/exceptions/unauthorized.py b/seed/fastapi/inline-types/core/exceptions/unauthorized.py new file mode 100644 index 00000000000..32d532e5ef2 --- /dev/null +++ b/seed/fastapi/inline-types/core/exceptions/unauthorized.py @@ -0,0 +1,15 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from .fern_http_exception import FernHTTPException + + +class UnauthorizedException(FernHTTPException): + """ + This is the exception that is thrown by Fern when auth is not present on a + request. + """ + + def __init__(self, content: typing.Optional[str] = None) -> None: + super().__init__(status_code=401, content=content) diff --git a/seed/fastapi/inline-types/core/pydantic_utilities.py b/seed/fastapi/inline-types/core/pydantic_utilities.py new file mode 100644 index 00000000000..fe6359cf503 --- /dev/null +++ b/seed/fastapi/inline-types/core/pydantic_utilities.py @@ -0,0 +1,273 @@ +# This file was auto-generated by Fern from our API Definition. + +# nopycln: file +import datetime as dt +import typing +from collections import defaultdict + +import typing_extensions + +import pydantic + +from .datetime_utils import serialize_datetime + +IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + +if IS_PYDANTIC_V2: + # isort will try to reformat the comments on these imports, which breaks mypy + # isort: off + from pydantic.v1.datetime_parse import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + parse_date as parse_date, + ) + from pydantic.v1.datetime_parse import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + parse_datetime as parse_datetime, + ) + from pydantic.v1.json import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + ENCODERS_BY_TYPE as encoders_by_type, + ) + from pydantic.v1.typing import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + get_args as get_args, + ) + from pydantic.v1.typing import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + get_origin as get_origin, + ) + from pydantic.v1.typing import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + is_literal_type as is_literal_type, + ) + from pydantic.v1.typing import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + is_union as is_union, + ) + from pydantic.v1.fields import ModelField as ModelField # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 +else: + from pydantic.datetime_parse import parse_date as parse_date # type: ignore # Pydantic v1 + from pydantic.datetime_parse import parse_datetime as parse_datetime # type: ignore # Pydantic v1 + from pydantic.fields import ModelField as ModelField # type: ignore # Pydantic v1 + from pydantic.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore # Pydantic v1 + from pydantic.typing import get_args as get_args # type: ignore # Pydantic v1 + from pydantic.typing import get_origin as get_origin # type: ignore # Pydantic v1 + from pydantic.typing import is_literal_type as is_literal_type # type: ignore # Pydantic v1 + from pydantic.typing import is_union as is_union # type: ignore # Pydantic v1 + + # isort: on + + +T = typing.TypeVar("T") +Model = typing.TypeVar("Model", bound=pydantic.BaseModel) + + +def parse_obj_as(type_: typing.Type[T], object_: typing.Any) -> T: + if IS_PYDANTIC_V2: + adapter = pydantic.TypeAdapter(type_) # type: ignore # Pydantic v2 + return adapter.validate_python(object_) + else: + return pydantic.parse_obj_as(type_, object_) + + +def to_jsonable_with_fallback( + obj: typing.Any, fallback_serializer: typing.Callable[[typing.Any], typing.Any] +) -> typing.Any: + if IS_PYDANTIC_V2: + from pydantic_core import to_jsonable_python + + return to_jsonable_python(obj, fallback=fallback_serializer) + else: + return fallback_serializer(obj) + + +class UniversalBaseModel(pydantic.BaseModel): + class Config: + populate_by_name = True + smart_union = True + allow_population_by_field_name = True + json_encoders = {dt.datetime: serialize_datetime} + # Allow fields begining with `model_` to be used in the model + protected_namespaces = () + + def json(self, **kwargs: typing.Any) -> str: + kwargs_with_defaults: typing.Any = { + "by_alias": True, + "exclude_unset": True, + **kwargs, + } + if IS_PYDANTIC_V2: + return super().model_dump_json(**kwargs_with_defaults) # type: ignore # Pydantic v2 + else: + return super().json(**kwargs_with_defaults) + + def dict(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + """ + Override the default dict method to `exclude_unset` by default. This function patches + `exclude_unset` to work include fields within non-None default values. + """ + # Note: the logic here is multi-plexed given the levers exposed in Pydantic V1 vs V2 + # Pydantic V1's .dict can be extremely slow, so we do not want to call it twice. + # + # We'd ideally do the same for Pydantic V2, but it shells out to a library to serialize models + # that we have less control over, and this is less intrusive than custom serializers for now. + if IS_PYDANTIC_V2: + kwargs_with_defaults_exclude_unset: typing.Any = { + **kwargs, + "by_alias": True, + "exclude_unset": True, + "exclude_none": False, + } + kwargs_with_defaults_exclude_none: typing.Any = { + **kwargs, + "by_alias": True, + "exclude_none": True, + "exclude_unset": False, + } + return deep_union_pydantic_dicts( + super().model_dump(**kwargs_with_defaults_exclude_unset), # type: ignore # Pydantic v2 + super().model_dump(**kwargs_with_defaults_exclude_none), # type: ignore # Pydantic v2 + ) + + else: + _fields_set = self.__fields_set__.copy() + + fields = _get_model_fields(self.__class__) + for name, field in fields.items(): + if name not in _fields_set: + default = _get_field_default(field) + + # If the default values are non-null act like they've been set + # This effectively allows exclude_unset to work like exclude_none where + # the latter passes through intentionally set none values. + if default is not None or ( + "exclude_unset" in kwargs and not kwargs["exclude_unset"] + ): + _fields_set.add(name) + + if default is not None: + self.__fields_set__.add(name) + + kwargs_with_defaults_exclude_unset_include_fields: typing.Any = { + "by_alias": True, + "exclude_unset": True, + "include": _fields_set, + **kwargs, + } + + return super().dict(**kwargs_with_defaults_exclude_unset_include_fields) + + +def _union_list_of_pydantic_dicts( + source: typing.List[typing.Any], destination: typing.List[typing.Any] +) -> typing.List[typing.Any]: + converted_list: typing.List[typing.Any] = [] + for i, item in enumerate(source): + destination_value = destination[i] # type: ignore + if isinstance(item, dict): + converted_list.append(deep_union_pydantic_dicts(item, destination_value)) + elif isinstance(item, list): + converted_list.append( + _union_list_of_pydantic_dicts(item, destination_value) + ) + else: + converted_list.append(item) + return converted_list + + +def deep_union_pydantic_dicts( + source: typing.Dict[str, typing.Any], destination: typing.Dict[str, typing.Any] +) -> typing.Dict[str, typing.Any]: + for key, value in source.items(): + node = destination.setdefault(key, {}) + if isinstance(value, dict): + deep_union_pydantic_dicts(value, node) + # Note: we do not do this same processing for sets given we do not have sets of models + # and given the sets are unordered, the processing of the set and matching objects would + # be non-trivial. + elif isinstance(value, list): + destination[key] = _union_list_of_pydantic_dicts(value, node) + else: + destination[key] = value + + return destination + + +if IS_PYDANTIC_V2: + + class V2RootModel(UniversalBaseModel, pydantic.RootModel): # type: ignore # Pydantic v2 + pass + + UniversalRootModel: typing_extensions.TypeAlias = V2RootModel # type: ignore +else: + UniversalRootModel: typing_extensions.TypeAlias = UniversalBaseModel # type: ignore + + +def encode_by_type(o: typing.Any) -> typing.Any: + encoders_by_class_tuples: typing.Dict[ + typing.Callable[[typing.Any], typing.Any], typing.Tuple[typing.Any, ...] + ] = defaultdict(tuple) + for type_, encoder in encoders_by_type.items(): + encoders_by_class_tuples[encoder] += (type_,) + + if type(o) in encoders_by_type: + return encoders_by_type[type(o)](o) + for encoder, classes_tuple in encoders_by_class_tuples.items(): + if isinstance(o, classes_tuple): + return encoder(o) + + +def update_forward_refs(model: typing.Type["Model"], **localns: typing.Any) -> None: + if IS_PYDANTIC_V2: + model.model_rebuild(raise_errors=False) # type: ignore # Pydantic v2 + else: + model.update_forward_refs(**localns) + + +# Mirrors Pydantic's internal typing +AnyCallable = typing.Callable[..., typing.Any] + + +def universal_root_validator( + pre: bool = False, +) -> typing.Callable[[AnyCallable], AnyCallable]: + def decorator(func: AnyCallable) -> AnyCallable: + if IS_PYDANTIC_V2: + return pydantic.model_validator(mode="before" if pre else "after")(func) # type: ignore # Pydantic v2 + else: + return pydantic.root_validator(pre=pre)(func) # type: ignore # Pydantic v1 + + return decorator + + +def universal_field_validator( + field_name: str, pre: bool = False +) -> typing.Callable[[AnyCallable], AnyCallable]: + def decorator(func: AnyCallable) -> AnyCallable: + if IS_PYDANTIC_V2: + return pydantic.field_validator( + field_name, mode="before" if pre else "after" + )(func) # type: ignore # Pydantic v2 + else: + return pydantic.validator(field_name, pre=pre)(func) # type: ignore # Pydantic v1 + + return decorator + + +PydanticField = typing.Union[ModelField, pydantic.fields.FieldInfo] + + +def _get_model_fields( + model: typing.Type["Model"], +) -> typing.Mapping[str, PydanticField]: + if IS_PYDANTIC_V2: + return model.model_fields # type: ignore # Pydantic v2 + else: + return model.__fields__ # type: ignore # Pydantic v1 + + +def _get_field_default(field: PydanticField) -> typing.Any: + try: + value = field.get_default() # type: ignore # Pydantic < v1.10.15 + except: + value = field.default + if IS_PYDANTIC_V2: + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + return value diff --git a/seed/fastapi/inline-types/core/route_args.py b/seed/fastapi/inline-types/core/route_args.py new file mode 100644 index 00000000000..bd940bf4ddd --- /dev/null +++ b/seed/fastapi/inline-types/core/route_args.py @@ -0,0 +1,73 @@ +# This file was auto-generated by Fern from our API Definition. + +import enum +import inspect +import typing + +import typing_extensions + +T = typing.TypeVar("T", bound=typing.Callable[..., typing.Any]) + +FERN_CONFIG_KEY = "__fern" + + +class RouteArgs(typing_extensions.TypedDict): + openapi_extra: typing.Optional[typing.Dict[str, typing.Any]] + tags: typing.Optional[typing.List[typing.Union[str, enum.Enum]]] + include_in_schema: bool + + +DEFAULT_ROUTE_ARGS = RouteArgs(openapi_extra=None, tags=None, include_in_schema=True) + + +def get_route_args( + endpoint_function: typing.Callable[..., typing.Any], *, default_tag: str +) -> RouteArgs: + unwrapped = inspect.unwrap( + endpoint_function, stop=(lambda f: hasattr(f, FERN_CONFIG_KEY)) + ) + route_args = typing.cast( + RouteArgs, getattr(unwrapped, FERN_CONFIG_KEY, DEFAULT_ROUTE_ARGS) + ) + if route_args["tags"] is None: + return RouteArgs( + openapi_extra=route_args["openapi_extra"], + tags=[default_tag], + include_in_schema=route_args["include_in_schema"], + ) + return route_args + + +def route_args( + openapi_extra: typing.Optional[typing.Dict[str, typing.Any]] = None, + tags: typing.Optional[typing.List[typing.Union[str, enum.Enum]]] = None, + include_in_schema: bool = True, +) -> typing.Callable[[T], T]: + """ + this decorator allows you to forward certain args to the FastAPI route decorator. + + usage: + @route_args(openapi_extra=...) + def your_endpoint_method(... + + currently supported args: + - openapi_extra + - tags + + if there's another FastAPI route arg you need to pass through, please + contact the Fern team! + """ + + def decorator(endpoint_function: T) -> T: + setattr( + endpoint_function, + FERN_CONFIG_KEY, + RouteArgs( + openapi_extra=openapi_extra, + tags=tags, + include_in_schema=include_in_schema, + ), + ) + return endpoint_function + + return decorator diff --git a/seed/fastapi/inline-types/core/security/__init__.py b/seed/fastapi/inline-types/core/security/__init__.py new file mode 100644 index 00000000000..e69ee6d9c5a --- /dev/null +++ b/seed/fastapi/inline-types/core/security/__init__.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +from .bearer import BearerToken + +__all__ = ["BearerToken"] diff --git a/seed/fastapi/inline-types/core/security/bearer.py b/seed/fastapi/inline-types/core/security/bearer.py new file mode 100644 index 00000000000..023342b668d --- /dev/null +++ b/seed/fastapi/inline-types/core/security/bearer.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +import fastapi + +from ..exceptions import UnauthorizedException + + +class BearerToken: + def __init__(self, token: str): + self.token = token + + +def HTTPBearer(request: fastapi.requests.Request) -> BearerToken: + authorization_header_value = request.headers.get("Authorization") + if not authorization_header_value: + raise UnauthorizedException("Missing Authorization header") + scheme, _, token = authorization_header_value.partition(" ") + if scheme.lower() != "bearer": + raise UnauthorizedException("Authorization header scheme is not bearer") + if not token: + raise UnauthorizedException("Authorization header is missing a token") + return BearerToken(token) diff --git a/seed/fastapi/inline-types/core/serialization.py b/seed/fastapi/inline-types/core/serialization.py new file mode 100644 index 00000000000..5679deb8a56 --- /dev/null +++ b/seed/fastapi/inline-types/core/serialization.py @@ -0,0 +1,276 @@ +# This file was auto-generated by Fern from our API Definition. + +import collections +import inspect +import typing + +import typing_extensions + +import pydantic + + +class FieldMetadata: + """ + Metadata class used to annotate fields to provide additional information. + + Example: + class MyDict(TypedDict): + field: typing.Annotated[str, FieldMetadata(alias="field_name")] + + Will serialize: `{"field": "value"}` + To: `{"field_name": "value"}` + """ + + alias: str + + def __init__(self, *, alias: str) -> None: + self.alias = alias + + +def convert_and_respect_annotation_metadata( + *, + object_: typing.Any, + annotation: typing.Any, + inner_type: typing.Optional[typing.Any] = None, + direction: typing.Literal["read", "write"], +) -> typing.Any: + """ + Respect the metadata annotations on a field, such as aliasing. This function effectively + manipulates the dict-form of an object to respect the metadata annotations. This is primarily used for + TypedDicts, which cannot support aliasing out of the box, and can be extended for additional + utilities, such as defaults. + + Parameters + ---------- + object_ : typing.Any + + annotation : type + The type we're looking to apply typing annotations from + + inner_type : typing.Optional[type] + + Returns + ------- + typing.Any + """ + + if object_ is None: + return None + if inner_type is None: + inner_type = annotation + + clean_type = _remove_annotations(inner_type) + # Pydantic models + if ( + inspect.isclass(clean_type) + and issubclass(clean_type, pydantic.BaseModel) + and isinstance(object_, typing.Mapping) + ): + return _convert_mapping(object_, clean_type, direction) + # TypedDicts + if typing_extensions.is_typeddict(clean_type) and isinstance( + object_, typing.Mapping + ): + return _convert_mapping(object_, clean_type, direction) + + if ( + typing_extensions.get_origin(clean_type) == typing.Dict + or typing_extensions.get_origin(clean_type) == dict + or clean_type == typing.Dict + ) and isinstance(object_, typing.Dict): + key_type = typing_extensions.get_args(clean_type)[0] + value_type = typing_extensions.get_args(clean_type)[1] + + return { + key: convert_and_respect_annotation_metadata( + object_=value, + annotation=annotation, + inner_type=value_type, + direction=direction, + ) + for key, value in object_.items() + } + + # If you're iterating on a string, do not bother to coerce it to a sequence. + if not isinstance(object_, str): + if ( + typing_extensions.get_origin(clean_type) == typing.Set + or typing_extensions.get_origin(clean_type) == set + or clean_type == typing.Set + ) and isinstance(object_, typing.Set): + inner_type = typing_extensions.get_args(clean_type)[0] + return { + convert_and_respect_annotation_metadata( + object_=item, + annotation=annotation, + inner_type=inner_type, + direction=direction, + ) + for item in object_ + } + elif ( + ( + typing_extensions.get_origin(clean_type) == typing.List + or typing_extensions.get_origin(clean_type) == list + or clean_type == typing.List + ) + and isinstance(object_, typing.List) + ) or ( + ( + typing_extensions.get_origin(clean_type) == typing.Sequence + or typing_extensions.get_origin(clean_type) == collections.abc.Sequence + or clean_type == typing.Sequence + ) + and isinstance(object_, typing.Sequence) + ): + inner_type = typing_extensions.get_args(clean_type)[0] + return [ + convert_and_respect_annotation_metadata( + object_=item, + annotation=annotation, + inner_type=inner_type, + direction=direction, + ) + for item in object_ + ] + + if typing_extensions.get_origin(clean_type) == typing.Union: + # We should be able to ~relatively~ safely try to convert keys against all + # member types in the union, the edge case here is if one member aliases a field + # of the same name to a different name from another member + # Or if another member aliases a field of the same name that another member does not. + for member in typing_extensions.get_args(clean_type): + object_ = convert_and_respect_annotation_metadata( + object_=object_, + annotation=annotation, + inner_type=member, + direction=direction, + ) + return object_ + + annotated_type = _get_annotation(annotation) + if annotated_type is None: + return object_ + + # If the object is not a TypedDict, a Union, or other container (list, set, sequence, etc.) + # Then we can safely call it on the recursive conversion. + return object_ + + +def _convert_mapping( + object_: typing.Mapping[str, object], + expected_type: typing.Any, + direction: typing.Literal["read", "write"], +) -> typing.Mapping[str, object]: + converted_object: typing.Dict[str, object] = {} + annotations = typing_extensions.get_type_hints(expected_type, include_extras=True) + aliases_to_field_names = _get_alias_to_field_name(annotations) + for key, value in object_.items(): + if direction == "read" and key in aliases_to_field_names: + dealiased_key = aliases_to_field_names.get(key) + if dealiased_key is not None: + type_ = annotations.get(dealiased_key) + else: + type_ = annotations.get(key) + # Note you can't get the annotation by the field name if you're in read mode, so you must check the aliases map + # + # So this is effectively saying if we're in write mode, and we don't have a type, or if we're in read mode and we don't have an alias + # then we can just pass the value through as is + if type_ is None: + converted_object[key] = value + elif direction == "read" and key not in aliases_to_field_names: + converted_object[key] = convert_and_respect_annotation_metadata( + object_=value, annotation=type_, direction=direction + ) + else: + converted_object[ + _alias_key(key, type_, direction, aliases_to_field_names) + ] = convert_and_respect_annotation_metadata( + object_=value, annotation=type_, direction=direction + ) + return converted_object + + +def _get_annotation(type_: typing.Any) -> typing.Optional[typing.Any]: + maybe_annotated_type = typing_extensions.get_origin(type_) + if maybe_annotated_type is None: + return None + + if maybe_annotated_type == typing_extensions.NotRequired: + type_ = typing_extensions.get_args(type_)[0] + maybe_annotated_type = typing_extensions.get_origin(type_) + + if maybe_annotated_type == typing_extensions.Annotated: + return type_ + + return None + + +def _remove_annotations(type_: typing.Any) -> typing.Any: + maybe_annotated_type = typing_extensions.get_origin(type_) + if maybe_annotated_type is None: + return type_ + + if maybe_annotated_type == typing_extensions.NotRequired: + return _remove_annotations(typing_extensions.get_args(type_)[0]) + + if maybe_annotated_type == typing_extensions.Annotated: + return _remove_annotations(typing_extensions.get_args(type_)[0]) + + return type_ + + +def get_alias_to_field_mapping(type_: typing.Any) -> typing.Dict[str, str]: + annotations = typing_extensions.get_type_hints(type_, include_extras=True) + return _get_alias_to_field_name(annotations) + + +def get_field_to_alias_mapping(type_: typing.Any) -> typing.Dict[str, str]: + annotations = typing_extensions.get_type_hints(type_, include_extras=True) + return _get_field_to_alias_name(annotations) + + +def _get_alias_to_field_name( + field_to_hint: typing.Dict[str, typing.Any], +) -> typing.Dict[str, str]: + aliases = {} + for field, hint in field_to_hint.items(): + maybe_alias = _get_alias_from_type(hint) + if maybe_alias is not None: + aliases[maybe_alias] = field + return aliases + + +def _get_field_to_alias_name( + field_to_hint: typing.Dict[str, typing.Any], +) -> typing.Dict[str, str]: + aliases = {} + for field, hint in field_to_hint.items(): + maybe_alias = _get_alias_from_type(hint) + if maybe_alias is not None: + aliases[field] = maybe_alias + return aliases + + +def _get_alias_from_type(type_: typing.Any) -> typing.Optional[str]: + maybe_annotated_type = _get_annotation(type_) + + if maybe_annotated_type is not None: + # The actual annotations are 1 onward, the first is the annotated type + annotations = typing_extensions.get_args(maybe_annotated_type)[1:] + + for annotation in annotations: + if isinstance(annotation, FieldMetadata) and annotation.alias is not None: + return annotation.alias + return None + + +def _alias_key( + key: str, + type_: typing.Any, + direction: typing.Literal["read", "write"], + aliases_to_field_names: typing.Dict[str, str], +) -> str: + if direction == "read": + return aliases_to_field_names.get(key, key) + return _get_alias_from_type(type_=type_) or key diff --git a/seed/fastapi/inline-types/register.py b/seed/fastapi/inline-types/register.py new file mode 100644 index 00000000000..ce9f38fa412 --- /dev/null +++ b/seed/fastapi/inline-types/register.py @@ -0,0 +1,48 @@ +# This file was auto-generated by Fern from our API Definition. + +import fastapi +from .service.service import AbstractRootService +import typing +from fastapi import params +from .core.exceptions.fern_http_exception import FernHTTPException +from .core.exceptions import fern_http_exception_handler +import starlette.exceptions +from .core.exceptions import http_exception_handler +from .core.exceptions import default_exception_handler +from .core.abstract_fern_service import AbstractFernService +import types +import os +import glob +import importlib + + +def register( + _app: fastapi.FastAPI, + *, + root: AbstractRootService, + dependencies: typing.Optional[typing.Sequence[params.Depends]] = None, +) -> None: + _app.include_router(__register_service(root), dependencies=dependencies) + + _app.add_exception_handler(FernHTTPException, fern_http_exception_handler) # type: ignore + _app.add_exception_handler( + starlette.exceptions.HTTPException, http_exception_handler + ) # type: ignore + _app.add_exception_handler(Exception, default_exception_handler) # type: ignore + + +def __register_service(service: AbstractFernService) -> fastapi.APIRouter: + router = fastapi.APIRouter() + type(service)._init_fern(router) + return router + + +def register_validators(module: types.ModuleType) -> None: + validators_directory: str = os.path.dirname(module.__file__) # type: ignore + for path in glob.glob( + os.path.join(validators_directory, "**/*.py"), recursive=True + ): + if os.path.isfile(path): + relative_path = os.path.relpath(path, start=validators_directory) + module_path = ".".join([module.__name__] + relative_path[:-3].split("/")) + importlib.import_module(module_path) diff --git a/seed/fastapi/inline-types/service/__init__.py b/seed/fastapi/inline-types/service/__init__.py new file mode 100644 index 00000000000..8ff97f9a38d --- /dev/null +++ b/seed/fastapi/inline-types/service/__init__.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +from .post_root_request import PostRootRequest +from .service import AbstractRootService + +__all__ = ["AbstractRootService", "PostRootRequest"] diff --git a/seed/fastapi/inline-types/service/post_root_request.py b/seed/fastapi/inline-types/service/post_root_request.py new file mode 100644 index 00000000000..daa4e9c3328 --- /dev/null +++ b/seed/fastapi/inline-types/service/post_root_request.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +from ..core.pydantic_utilities import UniversalBaseModel +from ..types.inline_type_1 import InlineType1 +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +import typing +import pydantic + + +class PostRootRequest(UniversalBaseModel): + bar: InlineType1 + foo: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="forbid" + ) # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.forbid diff --git a/seed/fastapi/inline-types/service/service.py b/seed/fastapi/inline-types/service/service.py new file mode 100644 index 00000000000..dd90aa43cd6 --- /dev/null +++ b/seed/fastapi/inline-types/service/service.py @@ -0,0 +1,77 @@ +# This file was auto-generated by Fern from our API Definition. + +from ..core.abstract_fern_service import AbstractFernService +from .post_root_request import PostRootRequest +from ..types.root_type_1 import RootType1 +import abc +import fastapi +import inspect +import typing +from ..core.exceptions.fern_http_exception import FernHTTPException +import logging +import functools +from ..core.route_args import get_route_args + + +class AbstractRootService(AbstractFernService): + """ + AbstractRootService is an abstract class containing the methods that you should implement. + + Each method is associated with an API route, which will be registered + with FastAPI when you register your implementation using Fern's register() + function. + """ + + @abc.abstractmethod + def get_root(self, *, body: PostRootRequest) -> RootType1: ... + + """ + Below are internal methods used by Fern to register your implementation. + You can ignore them. + """ + + @classmethod + def _init_fern(cls, router: fastapi.APIRouter) -> None: + cls.__init_get_root(router=router) + + @classmethod + def __init_get_root(cls, router: fastapi.APIRouter) -> None: + endpoint_function = inspect.signature(cls.get_root) + new_parameters: typing.List[inspect.Parameter] = [] + for index, (parameter_name, parameter) in enumerate( + endpoint_function.parameters.items() + ): + if index == 0: + new_parameters.append(parameter.replace(default=fastapi.Depends(cls))) + elif parameter_name == "body": + new_parameters.append(parameter.replace(default=fastapi.Body(...))) + else: + new_parameters.append(parameter) + setattr( + cls.get_root, + "__signature__", + endpoint_function.replace(parameters=new_parameters), + ) + + @functools.wraps(cls.get_root) + def wrapper(*args: typing.Any, **kwargs: typing.Any) -> RootType1: + try: + return cls.get_root(*args, **kwargs) + except FernHTTPException as e: + logging.getLogger(f"{cls.__module__}.{cls.__name__}").warn( + f"Endpoint 'get_root' unexpectedly threw {e.__class__.__name__}. " + + f"If this was intentional, please add {e.__class__.__name__} to " + + "the endpoint's errors list in your Fern Definition." + ) + raise e + + # this is necessary for FastAPI to find forward-ref'ed type hints. + # https://github.com/tiangolo/fastapi/pull/5077 + wrapper.__globals__.update(cls.get_root.__globals__) + + router.post( + path="/root/root", + response_model=RootType1, + description=AbstractRootService.get_root.__doc__, + **get_route_args(cls.get_root, default_tag=""), + )(wrapper) diff --git a/seed/fastapi/inline-types/snippet-templates.json b/seed/fastapi/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/fastapi/inline-types/snippet.json b/seed/fastapi/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/fastapi/inline-types/types/__init__.py b/seed/fastapi/inline-types/types/__init__.py new file mode 100644 index 00000000000..b143012e4f8 --- /dev/null +++ b/seed/fastapi/inline-types/types/__init__.py @@ -0,0 +1,19 @@ +# This file was auto-generated by Fern from our API Definition. + +from .inline_enum import InlineEnum +from .inline_type_1 import InlineType1 +from .inline_type_2 import InlineType2 +from .inlined_discriminated_union_1 import InlinedDiscriminatedUnion1 +from .inlined_undiscriminated_union_1 import InlinedUndiscriminatedUnion1 +from .nested_inline_type_1 import NestedInlineType1 +from .root_type_1 import RootType1 + +__all__ = [ + "InlineEnum", + "InlineType1", + "InlineType2", + "InlinedDiscriminatedUnion1", + "InlinedUndiscriminatedUnion1", + "NestedInlineType1", + "RootType1", +] diff --git a/seed/fastapi/inline-types/types/inline_enum.py b/seed/fastapi/inline-types/types/inline_enum.py new file mode 100644 index 00000000000..b46f498c663 --- /dev/null +++ b/seed/fastapi/inline-types/types/inline_enum.py @@ -0,0 +1,29 @@ +# This file was auto-generated by Fern from our API Definition. + +import enum +import typing + +T_Result = typing.TypeVar("T_Result") + + +class InlineEnum(str, enum.Enum): + SUNNY = "SUNNY" + CLOUDY = "CLOUDY" + RAINING = "RAINING" + SNOWING = "SNOWING" + + def visit( + self, + sunny: typing.Callable[[], T_Result], + cloudy: typing.Callable[[], T_Result], + raining: typing.Callable[[], T_Result], + snowing: typing.Callable[[], T_Result], + ) -> T_Result: + if self is InlineEnum.SUNNY: + return sunny() + if self is InlineEnum.CLOUDY: + return cloudy() + if self is InlineEnum.RAINING: + return raining() + if self is InlineEnum.SNOWING: + return snowing() diff --git a/seed/fastapi/inline-types/types/inline_type_1.py b/seed/fastapi/inline-types/types/inline_type_1.py new file mode 100644 index 00000000000..26b6b8c2cf1 --- /dev/null +++ b/seed/fastapi/inline-types/types/inline_type_1.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +from ..core.pydantic_utilities import UniversalBaseModel +from .nested_inline_type_1 import NestedInlineType1 +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +import typing +import pydantic + + +class InlineType1(UniversalBaseModel): + foo: str + bar: NestedInlineType1 + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="forbid" + ) # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.forbid diff --git a/seed/fastapi/inline-types/types/inline_type_2.py b/seed/fastapi/inline-types/types/inline_type_2.py new file mode 100644 index 00000000000..37881d22fa6 --- /dev/null +++ b/seed/fastapi/inline-types/types/inline_type_2.py @@ -0,0 +1,19 @@ +# This file was auto-generated by Fern from our API Definition. + +from ..core.pydantic_utilities import UniversalBaseModel +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +import typing +import pydantic + + +class InlineType2(UniversalBaseModel): + baz: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="forbid" + ) # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.forbid diff --git a/seed/fastapi/inline-types/types/inlined_discriminated_union_1.py b/seed/fastapi/inline-types/types/inlined_discriminated_union_1.py new file mode 100644 index 00000000000..b3bff091fd1 --- /dev/null +++ b/seed/fastapi/inline-types/types/inlined_discriminated_union_1.py @@ -0,0 +1,108 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations +from .inline_type_1 import InlineType1 +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from .inline_type_2 import InlineType2 +from ..core.pydantic_utilities import UniversalRootModel +import typing +import typing_extensions +import pydantic +from ..core.pydantic_utilities import update_forward_refs + +T_Result = typing.TypeVar("T_Result") + + +class _Factory: + def type_1(self, value: InlineType1) -> InlinedDiscriminatedUnion1: + if IS_PYDANTIC_V2: + return InlinedDiscriminatedUnion1( + root=_InlinedDiscriminatedUnion1.Type1( + **value.dict(exclude_unset=True), type="type1" + ) + ) # type: ignore + else: + return InlinedDiscriminatedUnion1( + __root__=_InlinedDiscriminatedUnion1.Type1( + **value.dict(exclude_unset=True), type="type1" + ) + ) # type: ignore + + def type_2(self, value: InlineType2) -> InlinedDiscriminatedUnion1: + if IS_PYDANTIC_V2: + return InlinedDiscriminatedUnion1( + root=_InlinedDiscriminatedUnion1.Type2( + **value.dict(exclude_unset=True), type="type2" + ) + ) # type: ignore + else: + return InlinedDiscriminatedUnion1( + __root__=_InlinedDiscriminatedUnion1.Type2( + **value.dict(exclude_unset=True), type="type2" + ) + ) # type: ignore + + +class InlinedDiscriminatedUnion1(UniversalRootModel): + factory: typing.ClassVar[_Factory] = _Factory() + + if IS_PYDANTIC_V2: + root: typing_extensions.Annotated[ + typing.Union[ + _InlinedDiscriminatedUnion1.Type1, _InlinedDiscriminatedUnion1.Type2 + ], + pydantic.Field(discriminator="type"), + ] + + def get_as_union( + self, + ) -> typing.Union[ + _InlinedDiscriminatedUnion1.Type1, _InlinedDiscriminatedUnion1.Type2 + ]: + return self.root + else: + __root__: typing_extensions.Annotated[ + typing.Union[ + _InlinedDiscriminatedUnion1.Type1, _InlinedDiscriminatedUnion1.Type2 + ], + pydantic.Field(discriminator="type"), + ] + + def get_as_union( + self, + ) -> typing.Union[ + _InlinedDiscriminatedUnion1.Type1, _InlinedDiscriminatedUnion1.Type2 + ]: + return self.__root__ + + def dict(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + if IS_PYDANTIC_V2: + return self.root.dict(**kwargs) + else: + return self.__root__.dict(**kwargs) + + def visit( + self, + type_1: typing.Callable[[InlineType1], T_Result], + type_2: typing.Callable[[InlineType2], T_Result], + ) -> T_Result: + unioned_value = self.get_as_union() + if unioned_value.type == "type1": + return type_1( + InlineType1(**unioned_value.dict(exclude_unset=True, exclude={"type"})) + ) + if unioned_value.type == "type2": + return type_2( + InlineType2(**unioned_value.dict(exclude_unset=True, exclude={"type"})) + ) + + +class _InlinedDiscriminatedUnion1: + class Type1(InlineType1): + type: typing.Literal["type1"] = "type1" + + class Type2(InlineType2): + type: typing.Literal["type2"] = "type2" + + +update_forward_refs(InlinedDiscriminatedUnion1) diff --git a/seed/fastapi/inline-types/types/inlined_undiscriminated_union_1.py b/seed/fastapi/inline-types/types/inlined_undiscriminated_union_1.py new file mode 100644 index 00000000000..aaf1f02b5df --- /dev/null +++ b/seed/fastapi/inline-types/types/inlined_undiscriminated_union_1.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from .inline_type_1 import InlineType1 +from .inline_type_2 import InlineType2 + +InlinedUndiscriminatedUnion1 = typing.Union[InlineType1, InlineType2] diff --git a/seed/fastapi/inline-types/types/nested_inline_type_1.py b/seed/fastapi/inline-types/types/nested_inline_type_1.py new file mode 100644 index 00000000000..1e1f501b2a4 --- /dev/null +++ b/seed/fastapi/inline-types/types/nested_inline_type_1.py @@ -0,0 +1,22 @@ +# This file was auto-generated by Fern from our API Definition. + +from ..core.pydantic_utilities import UniversalBaseModel +from .inline_enum import InlineEnum +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +import typing + + +class NestedInlineType1(UniversalBaseModel): + foo: str + bar: str + my_enum: InlineEnum = pydantic.Field(alias="myEnum") + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="forbid" + ) # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.forbid diff --git a/seed/fastapi/inline-types/types/root_type_1.py b/seed/fastapi/inline-types/types/root_type_1.py new file mode 100644 index 00000000000..cae376ec55d --- /dev/null +++ b/seed/fastapi/inline-types/types/root_type_1.py @@ -0,0 +1,21 @@ +# This file was auto-generated by Fern from our API Definition. + +from ..core.pydantic_utilities import UniversalBaseModel +from .inline_type_1 import InlineType1 +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +import typing +import pydantic + + +class RootType1(UniversalBaseModel): + foo: str + bar: InlineType1 + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( + extra="forbid" + ) # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.forbid diff --git a/seed/go-fiber/inline-types/.github/workflows/ci.yml b/seed/go-fiber/inline-types/.github/workflows/ci.yml new file mode 100644 index 00000000000..d4c0a5dcd95 --- /dev/null +++ b/seed/go-fiber/inline-types/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Compile + run: go build ./... + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Test + run: go test ./... diff --git a/seed/go-fiber/inline-types/.mock/definition/__package__.yml b/seed/go-fiber/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/go-fiber/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/go-fiber/inline-types/.mock/definition/api.yml b/seed/go-fiber/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/go-fiber/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/go-fiber/inline-types/.mock/fern.config.json b/seed/go-fiber/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/go-fiber/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/go-fiber/inline-types/.mock/generators.yml b/seed/go-fiber/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/go-fiber/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/go-fiber/inline-types/go.mod b/seed/go-fiber/inline-types/go.mod new file mode 100644 index 00000000000..bc88e459c8c --- /dev/null +++ b/seed/go-fiber/inline-types/go.mod @@ -0,0 +1,8 @@ +module github.com/inline-types/fern + +go 1.13 + +require ( + github.com/stretchr/testify v1.7.0 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/seed/go-fiber/inline-types/go.sum b/seed/go-fiber/inline-types/go.sum new file mode 100644 index 00000000000..fc3dd9e67e8 --- /dev/null +++ b/seed/go-fiber/inline-types/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/seed/go-fiber/inline-types/internal/extra_properties.go b/seed/go-fiber/inline-types/internal/extra_properties.go new file mode 100644 index 00000000000..540c3fd89ee --- /dev/null +++ b/seed/go-fiber/inline-types/internal/extra_properties.go @@ -0,0 +1,141 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// MarshalJSONWithExtraProperty marshals the given value to JSON, including the extra property. +func MarshalJSONWithExtraProperty(marshaler interface{}, key string, value interface{}) ([]byte, error) { + return MarshalJSONWithExtraProperties(marshaler, map[string]interface{}{key: value}) +} + +// MarshalJSONWithExtraProperties marshals the given value to JSON, including any extra properties. +func MarshalJSONWithExtraProperties(marshaler interface{}, extraProperties map[string]interface{}) ([]byte, error) { + bytes, err := json.Marshal(marshaler) + if err != nil { + return nil, err + } + if len(extraProperties) == 0 { + return bytes, nil + } + keys, err := getKeys(marshaler) + if err != nil { + return nil, err + } + for _, key := range keys { + if _, ok := extraProperties[key]; ok { + return nil, fmt.Errorf("cannot add extra property %q because it is already defined on the type", key) + } + } + extraBytes, err := json.Marshal(extraProperties) + if err != nil { + return nil, err + } + if isEmptyJSON(bytes) { + if isEmptyJSON(extraBytes) { + return bytes, nil + } + return extraBytes, nil + } + result := bytes[:len(bytes)-1] + result = append(result, ',') + result = append(result, extraBytes[1:len(extraBytes)-1]...) + result = append(result, '}') + return result, nil +} + +// ExtractExtraProperties extracts any extra properties from the given value. +func ExtractExtraProperties(bytes []byte, value interface{}, exclude ...string) (map[string]interface{}, error) { + val := reflect.ValueOf(value) + for val.Kind() == reflect.Ptr { + if val.IsNil() { + return nil, fmt.Errorf("value must be non-nil to extract extra properties") + } + val = val.Elem() + } + if err := json.Unmarshal(bytes, &value); err != nil { + return nil, err + } + var extraProperties map[string]interface{} + if err := json.Unmarshal(bytes, &extraProperties); err != nil { + return nil, err + } + for i := 0; i < val.Type().NumField(); i++ { + key := jsonKey(val.Type().Field(i)) + if key == "" || key == "-" { + continue + } + delete(extraProperties, key) + } + for _, key := range exclude { + delete(extraProperties, key) + } + if len(extraProperties) == 0 { + return nil, nil + } + return extraProperties, nil +} + +// getKeys returns the keys associated with the given value. The value must be a +// a struct or a map with string keys. +func getKeys(value interface{}) ([]string, error) { + val := reflect.ValueOf(value) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if !val.IsValid() { + return nil, nil + } + switch val.Kind() { + case reflect.Struct: + return getKeysForStructType(val.Type()), nil + case reflect.Map: + var keys []string + if val.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("cannot extract keys from %T; only structs and maps with string keys are supported", value) + } + for _, key := range val.MapKeys() { + keys = append(keys, key.String()) + } + return keys, nil + default: + return nil, fmt.Errorf("cannot extract keys from %T; only structs and maps with string keys are supported", value) + } +} + +// getKeysForStructType returns all the keys associated with the given struct type, +// visiting embedded fields recursively. +func getKeysForStructType(structType reflect.Type) []string { + if structType.Kind() == reflect.Pointer { + structType = structType.Elem() + } + if structType.Kind() != reflect.Struct { + return nil + } + var keys []string + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + if field.Anonymous { + keys = append(keys, getKeysForStructType(field.Type)...) + continue + } + keys = append(keys, jsonKey(field)) + } + return keys +} + +// jsonKey returns the JSON key from the struct tag of the given field, +// excluding the omitempty flag (if any). +func jsonKey(field reflect.StructField) string { + return strings.TrimSuffix(field.Tag.Get("json"), ",omitempty") +} + +// isEmptyJSON returns true if the given data is empty, the empty JSON object, or +// an explicit null. +func isEmptyJSON(data []byte) bool { + return len(data) <= 2 || bytes.Equal(data, []byte("null")) +} diff --git a/seed/go-fiber/inline-types/internal/extra_properties_test.go b/seed/go-fiber/inline-types/internal/extra_properties_test.go new file mode 100644 index 00000000000..aa2510ee512 --- /dev/null +++ b/seed/go-fiber/inline-types/internal/extra_properties_test.go @@ -0,0 +1,228 @@ +package internal + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testMarshaler struct { + Name string `json:"name"` + BirthDate time.Time `json:"birthDate"` + CreatedAt time.Time `json:"created_at"` +} + +func (t *testMarshaler) MarshalJSON() ([]byte, error) { + type embed testMarshaler + var marshaler = struct { + embed + BirthDate string `json:"birthDate"` + CreatedAt string `json:"created_at"` + }{ + embed: embed(*t), + BirthDate: t.BirthDate.Format("2006-01-02"), + CreatedAt: t.CreatedAt.Format(time.RFC3339), + } + return MarshalJSONWithExtraProperty(marshaler, "type", "test") +} + +func TestMarshalJSONWithExtraProperties(t *testing.T) { + tests := []struct { + desc string + giveMarshaler interface{} + giveExtraProperties map[string]interface{} + wantBytes []byte + wantError string + }{ + { + desc: "invalid type", + giveMarshaler: []string{"invalid"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot extract keys from []string; only structs and maps with string keys are supported`, + }, + { + desc: "invalid key type", + giveMarshaler: map[int]interface{}{42: "value"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot extract keys from map[int]interface {}; only structs and maps with string keys are supported`, + }, + { + desc: "invalid map overwrite", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot add extra property "key" because it is already defined on the type`, + }, + { + desc: "invalid struct overwrite", + giveMarshaler: new(testMarshaler), + giveExtraProperties: map[string]interface{}{"birthDate": "2000-01-01"}, + wantError: `cannot add extra property "birthDate" because it is already defined on the type`, + }, + { + desc: "invalid struct overwrite embedded type", + giveMarshaler: new(testMarshaler), + giveExtraProperties: map[string]interface{}{"name": "bob"}, + wantError: `cannot add extra property "name" because it is already defined on the type`, + }, + { + desc: "nil", + giveMarshaler: nil, + giveExtraProperties: nil, + wantBytes: []byte(`null`), + }, + { + desc: "empty", + giveMarshaler: map[string]interface{}{}, + giveExtraProperties: map[string]interface{}{}, + wantBytes: []byte(`{}`), + }, + { + desc: "no extra properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "only extra properties", + giveMarshaler: map[string]interface{}{}, + giveExtraProperties: map[string]interface{}{"key": "value"}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "single extra property", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"extra": "property"}, + wantBytes: []byte(`{"key":"value","extra":"property"}`), + }, + { + desc: "multiple extra properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"one": 1, "two": 2}, + wantBytes: []byte(`{"key":"value","one":1,"two":2}`), + }, + { + desc: "nested properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{ + "user": map[string]interface{}{ + "age": 42, + "name": "alice", + }, + }, + wantBytes: []byte(`{"key":"value","user":{"age":42,"name":"alice"}}`), + }, + { + desc: "multiple nested properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{ + "metadata": map[string]interface{}{ + "ip": "127.0.0.1", + }, + "user": map[string]interface{}{ + "age": 42, + "name": "alice", + }, + }, + wantBytes: []byte(`{"key":"value","metadata":{"ip":"127.0.0.1"},"user":{"age":42,"name":"alice"}}`), + }, + { + desc: "custom marshaler", + giveMarshaler: &testMarshaler{ + Name: "alice", + BirthDate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + giveExtraProperties: map[string]interface{}{ + "extra": "property", + }, + wantBytes: []byte(`{"name":"alice","birthDate":"2000-01-01","created_at":"2024-01-01T00:00:00Z","type":"test","extra":"property"}`), + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + bytes, err := MarshalJSONWithExtraProperties(tt.giveMarshaler, tt.giveExtraProperties) + if tt.wantError != "" { + require.EqualError(t, err, tt.wantError) + assert.Nil(t, tt.wantBytes) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantBytes, bytes) + + value := make(map[string]interface{}) + require.NoError(t, json.Unmarshal(bytes, &value)) + }) + } +} + +func TestExtractExtraProperties(t *testing.T) { + t.Run("none", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice"}`), value) + require.NoError(t, err) + assert.Nil(t, extraProperties) + }) + + t.Run("non-nil pointer", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("nil pointer", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + var value *user + _, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + assert.EqualError(t, err, "value must be non-nil to extract extra properties") + }) + + t.Run("non-zero value", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("zero value", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + var value user + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("exclude", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value, "age") + require.NoError(t, err) + assert.Nil(t, extraProperties) + }) +} diff --git a/seed/go-fiber/inline-types/internal/stringer.go b/seed/go-fiber/inline-types/internal/stringer.go new file mode 100644 index 00000000000..312801851e0 --- /dev/null +++ b/seed/go-fiber/inline-types/internal/stringer.go @@ -0,0 +1,13 @@ +package internal + +import "encoding/json" + +// StringifyJSON returns a pretty JSON string representation of +// the given value. +func StringifyJSON(value interface{}) (string, error) { + bytes, err := json.MarshalIndent(value, "", " ") + if err != nil { + return "", err + } + return string(bytes), nil +} diff --git a/seed/go-fiber/inline-types/internal/time.go b/seed/go-fiber/inline-types/internal/time.go new file mode 100644 index 00000000000..ab0e269fade --- /dev/null +++ b/seed/go-fiber/inline-types/internal/time.go @@ -0,0 +1,137 @@ +package internal + +import ( + "encoding/json" + "time" +) + +const dateFormat = "2006-01-02" + +// DateTime wraps time.Time and adapts its JSON representation +// to conform to a RFC3339 date (e.g. 2006-01-02). +// +// Ref: https://ijmacd.github.io/rfc3339-iso8601 +type Date struct { + t *time.Time +} + +// NewDate returns a new *Date. If the given time.Time +// is nil, nil will be returned. +func NewDate(t time.Time) *Date { + return &Date{t: &t} +} + +// NewOptionalDate returns a new *Date. If the given time.Time +// is nil, nil will be returned. +func NewOptionalDate(t *time.Time) *Date { + if t == nil { + return nil + } + return &Date{t: t} +} + +// Time returns the Date's underlying time, if any. If the +// date is nil, the zero value is returned. +func (d *Date) Time() time.Time { + if d == nil || d.t == nil { + return time.Time{} + } + return *d.t +} + +// TimePtr returns a pointer to the Date's underlying time.Time, if any. +func (d *Date) TimePtr() *time.Time { + if d == nil || d.t == nil { + return nil + } + if d.t.IsZero() { + return nil + } + return d.t +} + +func (d *Date) MarshalJSON() ([]byte, error) { + if d == nil || d.t == nil { + return nil, nil + } + return json.Marshal(d.t.Format(dateFormat)) +} + +func (d *Date) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + parsedTime, err := time.Parse(dateFormat, raw) + if err != nil { + return err + } + + *d = Date{t: &parsedTime} + return nil +} + +// DateTime wraps time.Time and adapts its JSON representation +// to conform to a RFC3339 date-time (e.g. 2017-07-21T17:32:28Z). +// +// Ref: https://ijmacd.github.io/rfc3339-iso8601 +type DateTime struct { + t *time.Time +} + +// NewDateTime returns a new *DateTime. +func NewDateTime(t time.Time) *DateTime { + return &DateTime{t: &t} +} + +// NewOptionalDateTime returns a new *DateTime. If the given time.Time +// is nil, nil will be returned. +func NewOptionalDateTime(t *time.Time) *DateTime { + if t == nil { + return nil + } + return &DateTime{t: t} +} + +// Time returns the DateTime's underlying time, if any. If the +// date-time is nil, the zero value is returned. +func (d *DateTime) Time() time.Time { + if d == nil || d.t == nil { + return time.Time{} + } + return *d.t +} + +// TimePtr returns a pointer to the DateTime's underlying time.Time, if any. +func (d *DateTime) TimePtr() *time.Time { + if d == nil || d.t == nil { + return nil + } + if d.t.IsZero() { + return nil + } + return d.t +} + +func (d *DateTime) MarshalJSON() ([]byte, error) { + if d == nil || d.t == nil { + return nil, nil + } + return json.Marshal(d.t.Format(time.RFC3339)) +} + +func (d *DateTime) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + parsedTime, err := time.Parse(time.RFC3339, raw) + if err != nil { + return err + } + + *d = DateTime{t: &parsedTime} + return nil +} diff --git a/seed/go-fiber/inline-types/snippet-templates.json b/seed/go-fiber/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/go-fiber/inline-types/snippet.json b/seed/go-fiber/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/go-fiber/inline-types/types.go b/seed/go-fiber/inline-types/types.go new file mode 100644 index 00000000000..bfd99d439c1 --- /dev/null +++ b/seed/go-fiber/inline-types/types.go @@ -0,0 +1,390 @@ +// This file was auto-generated by Fern from our API Definition. + +package object + +import ( + json "encoding/json" + fmt "fmt" + internal "github.com/inline-types/fern/internal" +) + +type PostRootRequest struct { + Bar *InlineType1 `json:"bar,omitempty" url:"-"` + Foo string `json:"foo" url:"-"` +} + +type InlineEnum string + +const ( + InlineEnumSunny InlineEnum = "SUNNY" + InlineEnumCloudy InlineEnum = "CLOUDY" + InlineEnumRaining InlineEnum = "RAINING" + InlineEnumSnowing InlineEnum = "SNOWING" +) + +func NewInlineEnumFromString(s string) (InlineEnum, error) { + switch s { + case "SUNNY": + return InlineEnumSunny, nil + case "CLOUDY": + return InlineEnumCloudy, nil + case "RAINING": + return InlineEnumRaining, nil + case "SNOWING": + return InlineEnumSnowing, nil + } + var t InlineEnum + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (i InlineEnum) Ptr() *InlineEnum { + return &i +} + +type InlineType1 struct { + Foo string `json:"foo" url:"foo"` + Bar *NestedInlineType1 `json:"bar,omitempty" url:"bar,omitempty"` + + extraProperties map[string]interface{} +} + +func (i *InlineType1) GetFoo() string { + if i == nil { + return "" + } + return i.Foo +} + +func (i *InlineType1) GetBar() *NestedInlineType1 { + if i == nil { + return nil + } + return i.Bar +} + +func (i *InlineType1) GetExtraProperties() map[string]interface{} { + return i.extraProperties +} + +func (i *InlineType1) UnmarshalJSON(data []byte) error { + type unmarshaler InlineType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *i = InlineType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *i) + if err != nil { + return err + } + i.extraProperties = extraProperties + return nil +} + +func (i *InlineType1) String() string { + if value, err := internal.StringifyJSON(i); err == nil { + return value + } + return fmt.Sprintf("%#v", i) +} + +type InlineType2 struct { + Baz string `json:"baz" url:"baz"` + + extraProperties map[string]interface{} +} + +func (i *InlineType2) GetBaz() string { + if i == nil { + return "" + } + return i.Baz +} + +func (i *InlineType2) GetExtraProperties() map[string]interface{} { + return i.extraProperties +} + +func (i *InlineType2) UnmarshalJSON(data []byte) error { + type unmarshaler InlineType2 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *i = InlineType2(value) + extraProperties, err := internal.ExtractExtraProperties(data, *i) + if err != nil { + return err + } + i.extraProperties = extraProperties + return nil +} + +func (i *InlineType2) String() string { + if value, err := internal.StringifyJSON(i); err == nil { + return value + } + return fmt.Sprintf("%#v", i) +} + +type InlinedDiscriminatedUnion1 struct { + Type string + Type1 *InlineType1 + Type2 *InlineType2 +} + +func NewInlinedDiscriminatedUnion1FromType1(value *InlineType1) *InlinedDiscriminatedUnion1 { + return &InlinedDiscriminatedUnion1{Type: "type1", Type1: value} +} + +func NewInlinedDiscriminatedUnion1FromType2(value *InlineType2) *InlinedDiscriminatedUnion1 { + return &InlinedDiscriminatedUnion1{Type: "type2", Type2: value} +} + +func (i *InlinedDiscriminatedUnion1) GetType() string { + if i == nil { + return "" + } + return i.Type +} + +func (i *InlinedDiscriminatedUnion1) GetType1() *InlineType1 { + if i == nil { + return nil + } + return i.Type1 +} + +func (i *InlinedDiscriminatedUnion1) GetType2() *InlineType2 { + if i == nil { + return nil + } + return i.Type2 +} + +func (i *InlinedDiscriminatedUnion1) UnmarshalJSON(data []byte) error { + var unmarshaler struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &unmarshaler); err != nil { + return err + } + i.Type = unmarshaler.Type + if unmarshaler.Type == "" { + return fmt.Errorf("%T did not include discriminant type", i) + } + switch unmarshaler.Type { + case "type1": + value := new(InlineType1) + if err := json.Unmarshal(data, &value); err != nil { + return err + } + i.Type1 = value + case "type2": + value := new(InlineType2) + if err := json.Unmarshal(data, &value); err != nil { + return err + } + i.Type2 = value + } + return nil +} + +func (i InlinedDiscriminatedUnion1) MarshalJSON() ([]byte, error) { + switch i.Type { + default: + return nil, fmt.Errorf("invalid type %s in %T", i.Type, i) + case "type1": + return internal.MarshalJSONWithExtraProperty(i.Type1, "type", "type1") + case "type2": + return internal.MarshalJSONWithExtraProperty(i.Type2, "type", "type2") + } +} + +type InlinedDiscriminatedUnion1Visitor interface { + VisitType1(*InlineType1) error + VisitType2(*InlineType2) error +} + +func (i *InlinedDiscriminatedUnion1) Accept(visitor InlinedDiscriminatedUnion1Visitor) error { + switch i.Type { + default: + return fmt.Errorf("invalid type %s in %T", i.Type, i) + case "type1": + return visitor.VisitType1(i.Type1) + case "type2": + return visitor.VisitType2(i.Type2) + } +} + +type InlinedUndiscriminatedUnion1 struct { + InlineType1 *InlineType1 + InlineType2 *InlineType2 + + typ string +} + +func NewInlinedUndiscriminatedUnion1FromInlineType1(value *InlineType1) *InlinedUndiscriminatedUnion1 { + return &InlinedUndiscriminatedUnion1{typ: "InlineType1", InlineType1: value} +} + +func NewInlinedUndiscriminatedUnion1FromInlineType2(value *InlineType2) *InlinedUndiscriminatedUnion1 { + return &InlinedUndiscriminatedUnion1{typ: "InlineType2", InlineType2: value} +} + +func (i *InlinedUndiscriminatedUnion1) GetInlineType1() *InlineType1 { + if i == nil { + return nil + } + return i.InlineType1 +} + +func (i *InlinedUndiscriminatedUnion1) GetInlineType2() *InlineType2 { + if i == nil { + return nil + } + return i.InlineType2 +} + +func (i *InlinedUndiscriminatedUnion1) UnmarshalJSON(data []byte) error { + valueInlineType1 := new(InlineType1) + if err := json.Unmarshal(data, &valueInlineType1); err == nil { + i.typ = "InlineType1" + i.InlineType1 = valueInlineType1 + return nil + } + valueInlineType2 := new(InlineType2) + if err := json.Unmarshal(data, &valueInlineType2); err == nil { + i.typ = "InlineType2" + i.InlineType2 = valueInlineType2 + return nil + } + return fmt.Errorf("%s cannot be deserialized as a %T", data, i) +} + +func (i InlinedUndiscriminatedUnion1) MarshalJSON() ([]byte, error) { + if i.typ == "InlineType1" || i.InlineType1 != nil { + return json.Marshal(i.InlineType1) + } + if i.typ == "InlineType2" || i.InlineType2 != nil { + return json.Marshal(i.InlineType2) + } + return nil, fmt.Errorf("type %T does not include a non-empty union type", i) +} + +type InlinedUndiscriminatedUnion1Visitor interface { + VisitInlineType1(*InlineType1) error + VisitInlineType2(*InlineType2) error +} + +func (i *InlinedUndiscriminatedUnion1) Accept(visitor InlinedUndiscriminatedUnion1Visitor) error { + if i.typ == "InlineType1" || i.InlineType1 != nil { + return visitor.VisitInlineType1(i.InlineType1) + } + if i.typ == "InlineType2" || i.InlineType2 != nil { + return visitor.VisitInlineType2(i.InlineType2) + } + return fmt.Errorf("type %T does not include a non-empty union type", i) +} + +type NestedInlineType1 struct { + Foo string `json:"foo" url:"foo"` + Bar string `json:"bar" url:"bar"` + MyEnum InlineEnum `json:"myEnum" url:"myEnum"` + + extraProperties map[string]interface{} +} + +func (n *NestedInlineType1) GetFoo() string { + if n == nil { + return "" + } + return n.Foo +} + +func (n *NestedInlineType1) GetBar() string { + if n == nil { + return "" + } + return n.Bar +} + +func (n *NestedInlineType1) GetMyEnum() InlineEnum { + if n == nil { + return "" + } + return n.MyEnum +} + +func (n *NestedInlineType1) GetExtraProperties() map[string]interface{} { + return n.extraProperties +} + +func (n *NestedInlineType1) UnmarshalJSON(data []byte) error { + type unmarshaler NestedInlineType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *n = NestedInlineType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *n) + if err != nil { + return err + } + n.extraProperties = extraProperties + return nil +} + +func (n *NestedInlineType1) String() string { + if value, err := internal.StringifyJSON(n); err == nil { + return value + } + return fmt.Sprintf("%#v", n) +} + +type RootType1 struct { + Foo string `json:"foo" url:"foo"` + Bar *InlineType1 `json:"bar,omitempty" url:"bar,omitempty"` + + extraProperties map[string]interface{} +} + +func (r *RootType1) GetFoo() string { + if r == nil { + return "" + } + return r.Foo +} + +func (r *RootType1) GetBar() *InlineType1 { + if r == nil { + return nil + } + return r.Bar +} + +func (r *RootType1) GetExtraProperties() map[string]interface{} { + return r.extraProperties +} + +func (r *RootType1) UnmarshalJSON(data []byte) error { + type unmarshaler RootType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *r = RootType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *r) + if err != nil { + return err + } + r.extraProperties = extraProperties + return nil +} + +func (r *RootType1) String() string { + if value, err := internal.StringifyJSON(r); err == nil { + return value + } + return fmt.Sprintf("%#v", r) +} diff --git a/seed/go-model/inline-types/.github/workflows/ci.yml b/seed/go-model/inline-types/.github/workflows/ci.yml new file mode 100644 index 00000000000..d4c0a5dcd95 --- /dev/null +++ b/seed/go-model/inline-types/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Compile + run: go build ./... + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Test + run: go test ./... diff --git a/seed/go-model/inline-types/.mock/definition/__package__.yml b/seed/go-model/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/go-model/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/go-model/inline-types/.mock/definition/api.yml b/seed/go-model/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/go-model/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/go-model/inline-types/.mock/fern.config.json b/seed/go-model/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/go-model/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/go-model/inline-types/.mock/generators.yml b/seed/go-model/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/go-model/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/go-model/inline-types/go.mod b/seed/go-model/inline-types/go.mod new file mode 100644 index 00000000000..bc88e459c8c --- /dev/null +++ b/seed/go-model/inline-types/go.mod @@ -0,0 +1,8 @@ +module github.com/inline-types/fern + +go 1.13 + +require ( + github.com/stretchr/testify v1.7.0 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/seed/go-model/inline-types/go.sum b/seed/go-model/inline-types/go.sum new file mode 100644 index 00000000000..fc3dd9e67e8 --- /dev/null +++ b/seed/go-model/inline-types/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/seed/go-model/inline-types/internal/extra_properties.go b/seed/go-model/inline-types/internal/extra_properties.go new file mode 100644 index 00000000000..540c3fd89ee --- /dev/null +++ b/seed/go-model/inline-types/internal/extra_properties.go @@ -0,0 +1,141 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// MarshalJSONWithExtraProperty marshals the given value to JSON, including the extra property. +func MarshalJSONWithExtraProperty(marshaler interface{}, key string, value interface{}) ([]byte, error) { + return MarshalJSONWithExtraProperties(marshaler, map[string]interface{}{key: value}) +} + +// MarshalJSONWithExtraProperties marshals the given value to JSON, including any extra properties. +func MarshalJSONWithExtraProperties(marshaler interface{}, extraProperties map[string]interface{}) ([]byte, error) { + bytes, err := json.Marshal(marshaler) + if err != nil { + return nil, err + } + if len(extraProperties) == 0 { + return bytes, nil + } + keys, err := getKeys(marshaler) + if err != nil { + return nil, err + } + for _, key := range keys { + if _, ok := extraProperties[key]; ok { + return nil, fmt.Errorf("cannot add extra property %q because it is already defined on the type", key) + } + } + extraBytes, err := json.Marshal(extraProperties) + if err != nil { + return nil, err + } + if isEmptyJSON(bytes) { + if isEmptyJSON(extraBytes) { + return bytes, nil + } + return extraBytes, nil + } + result := bytes[:len(bytes)-1] + result = append(result, ',') + result = append(result, extraBytes[1:len(extraBytes)-1]...) + result = append(result, '}') + return result, nil +} + +// ExtractExtraProperties extracts any extra properties from the given value. +func ExtractExtraProperties(bytes []byte, value interface{}, exclude ...string) (map[string]interface{}, error) { + val := reflect.ValueOf(value) + for val.Kind() == reflect.Ptr { + if val.IsNil() { + return nil, fmt.Errorf("value must be non-nil to extract extra properties") + } + val = val.Elem() + } + if err := json.Unmarshal(bytes, &value); err != nil { + return nil, err + } + var extraProperties map[string]interface{} + if err := json.Unmarshal(bytes, &extraProperties); err != nil { + return nil, err + } + for i := 0; i < val.Type().NumField(); i++ { + key := jsonKey(val.Type().Field(i)) + if key == "" || key == "-" { + continue + } + delete(extraProperties, key) + } + for _, key := range exclude { + delete(extraProperties, key) + } + if len(extraProperties) == 0 { + return nil, nil + } + return extraProperties, nil +} + +// getKeys returns the keys associated with the given value. The value must be a +// a struct or a map with string keys. +func getKeys(value interface{}) ([]string, error) { + val := reflect.ValueOf(value) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if !val.IsValid() { + return nil, nil + } + switch val.Kind() { + case reflect.Struct: + return getKeysForStructType(val.Type()), nil + case reflect.Map: + var keys []string + if val.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("cannot extract keys from %T; only structs and maps with string keys are supported", value) + } + for _, key := range val.MapKeys() { + keys = append(keys, key.String()) + } + return keys, nil + default: + return nil, fmt.Errorf("cannot extract keys from %T; only structs and maps with string keys are supported", value) + } +} + +// getKeysForStructType returns all the keys associated with the given struct type, +// visiting embedded fields recursively. +func getKeysForStructType(structType reflect.Type) []string { + if structType.Kind() == reflect.Pointer { + structType = structType.Elem() + } + if structType.Kind() != reflect.Struct { + return nil + } + var keys []string + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + if field.Anonymous { + keys = append(keys, getKeysForStructType(field.Type)...) + continue + } + keys = append(keys, jsonKey(field)) + } + return keys +} + +// jsonKey returns the JSON key from the struct tag of the given field, +// excluding the omitempty flag (if any). +func jsonKey(field reflect.StructField) string { + return strings.TrimSuffix(field.Tag.Get("json"), ",omitempty") +} + +// isEmptyJSON returns true if the given data is empty, the empty JSON object, or +// an explicit null. +func isEmptyJSON(data []byte) bool { + return len(data) <= 2 || bytes.Equal(data, []byte("null")) +} diff --git a/seed/go-model/inline-types/internal/extra_properties_test.go b/seed/go-model/inline-types/internal/extra_properties_test.go new file mode 100644 index 00000000000..aa2510ee512 --- /dev/null +++ b/seed/go-model/inline-types/internal/extra_properties_test.go @@ -0,0 +1,228 @@ +package internal + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testMarshaler struct { + Name string `json:"name"` + BirthDate time.Time `json:"birthDate"` + CreatedAt time.Time `json:"created_at"` +} + +func (t *testMarshaler) MarshalJSON() ([]byte, error) { + type embed testMarshaler + var marshaler = struct { + embed + BirthDate string `json:"birthDate"` + CreatedAt string `json:"created_at"` + }{ + embed: embed(*t), + BirthDate: t.BirthDate.Format("2006-01-02"), + CreatedAt: t.CreatedAt.Format(time.RFC3339), + } + return MarshalJSONWithExtraProperty(marshaler, "type", "test") +} + +func TestMarshalJSONWithExtraProperties(t *testing.T) { + tests := []struct { + desc string + giveMarshaler interface{} + giveExtraProperties map[string]interface{} + wantBytes []byte + wantError string + }{ + { + desc: "invalid type", + giveMarshaler: []string{"invalid"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot extract keys from []string; only structs and maps with string keys are supported`, + }, + { + desc: "invalid key type", + giveMarshaler: map[int]interface{}{42: "value"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot extract keys from map[int]interface {}; only structs and maps with string keys are supported`, + }, + { + desc: "invalid map overwrite", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot add extra property "key" because it is already defined on the type`, + }, + { + desc: "invalid struct overwrite", + giveMarshaler: new(testMarshaler), + giveExtraProperties: map[string]interface{}{"birthDate": "2000-01-01"}, + wantError: `cannot add extra property "birthDate" because it is already defined on the type`, + }, + { + desc: "invalid struct overwrite embedded type", + giveMarshaler: new(testMarshaler), + giveExtraProperties: map[string]interface{}{"name": "bob"}, + wantError: `cannot add extra property "name" because it is already defined on the type`, + }, + { + desc: "nil", + giveMarshaler: nil, + giveExtraProperties: nil, + wantBytes: []byte(`null`), + }, + { + desc: "empty", + giveMarshaler: map[string]interface{}{}, + giveExtraProperties: map[string]interface{}{}, + wantBytes: []byte(`{}`), + }, + { + desc: "no extra properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "only extra properties", + giveMarshaler: map[string]interface{}{}, + giveExtraProperties: map[string]interface{}{"key": "value"}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "single extra property", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"extra": "property"}, + wantBytes: []byte(`{"key":"value","extra":"property"}`), + }, + { + desc: "multiple extra properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"one": 1, "two": 2}, + wantBytes: []byte(`{"key":"value","one":1,"two":2}`), + }, + { + desc: "nested properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{ + "user": map[string]interface{}{ + "age": 42, + "name": "alice", + }, + }, + wantBytes: []byte(`{"key":"value","user":{"age":42,"name":"alice"}}`), + }, + { + desc: "multiple nested properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{ + "metadata": map[string]interface{}{ + "ip": "127.0.0.1", + }, + "user": map[string]interface{}{ + "age": 42, + "name": "alice", + }, + }, + wantBytes: []byte(`{"key":"value","metadata":{"ip":"127.0.0.1"},"user":{"age":42,"name":"alice"}}`), + }, + { + desc: "custom marshaler", + giveMarshaler: &testMarshaler{ + Name: "alice", + BirthDate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + giveExtraProperties: map[string]interface{}{ + "extra": "property", + }, + wantBytes: []byte(`{"name":"alice","birthDate":"2000-01-01","created_at":"2024-01-01T00:00:00Z","type":"test","extra":"property"}`), + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + bytes, err := MarshalJSONWithExtraProperties(tt.giveMarshaler, tt.giveExtraProperties) + if tt.wantError != "" { + require.EqualError(t, err, tt.wantError) + assert.Nil(t, tt.wantBytes) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantBytes, bytes) + + value := make(map[string]interface{}) + require.NoError(t, json.Unmarshal(bytes, &value)) + }) + } +} + +func TestExtractExtraProperties(t *testing.T) { + t.Run("none", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice"}`), value) + require.NoError(t, err) + assert.Nil(t, extraProperties) + }) + + t.Run("non-nil pointer", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("nil pointer", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + var value *user + _, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + assert.EqualError(t, err, "value must be non-nil to extract extra properties") + }) + + t.Run("non-zero value", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("zero value", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + var value user + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("exclude", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value, "age") + require.NoError(t, err) + assert.Nil(t, extraProperties) + }) +} diff --git a/seed/go-model/inline-types/internal/stringer.go b/seed/go-model/inline-types/internal/stringer.go new file mode 100644 index 00000000000..312801851e0 --- /dev/null +++ b/seed/go-model/inline-types/internal/stringer.go @@ -0,0 +1,13 @@ +package internal + +import "encoding/json" + +// StringifyJSON returns a pretty JSON string representation of +// the given value. +func StringifyJSON(value interface{}) (string, error) { + bytes, err := json.MarshalIndent(value, "", " ") + if err != nil { + return "", err + } + return string(bytes), nil +} diff --git a/seed/go-model/inline-types/internal/time.go b/seed/go-model/inline-types/internal/time.go new file mode 100644 index 00000000000..ab0e269fade --- /dev/null +++ b/seed/go-model/inline-types/internal/time.go @@ -0,0 +1,137 @@ +package internal + +import ( + "encoding/json" + "time" +) + +const dateFormat = "2006-01-02" + +// DateTime wraps time.Time and adapts its JSON representation +// to conform to a RFC3339 date (e.g. 2006-01-02). +// +// Ref: https://ijmacd.github.io/rfc3339-iso8601 +type Date struct { + t *time.Time +} + +// NewDate returns a new *Date. If the given time.Time +// is nil, nil will be returned. +func NewDate(t time.Time) *Date { + return &Date{t: &t} +} + +// NewOptionalDate returns a new *Date. If the given time.Time +// is nil, nil will be returned. +func NewOptionalDate(t *time.Time) *Date { + if t == nil { + return nil + } + return &Date{t: t} +} + +// Time returns the Date's underlying time, if any. If the +// date is nil, the zero value is returned. +func (d *Date) Time() time.Time { + if d == nil || d.t == nil { + return time.Time{} + } + return *d.t +} + +// TimePtr returns a pointer to the Date's underlying time.Time, if any. +func (d *Date) TimePtr() *time.Time { + if d == nil || d.t == nil { + return nil + } + if d.t.IsZero() { + return nil + } + return d.t +} + +func (d *Date) MarshalJSON() ([]byte, error) { + if d == nil || d.t == nil { + return nil, nil + } + return json.Marshal(d.t.Format(dateFormat)) +} + +func (d *Date) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + parsedTime, err := time.Parse(dateFormat, raw) + if err != nil { + return err + } + + *d = Date{t: &parsedTime} + return nil +} + +// DateTime wraps time.Time and adapts its JSON representation +// to conform to a RFC3339 date-time (e.g. 2017-07-21T17:32:28Z). +// +// Ref: https://ijmacd.github.io/rfc3339-iso8601 +type DateTime struct { + t *time.Time +} + +// NewDateTime returns a new *DateTime. +func NewDateTime(t time.Time) *DateTime { + return &DateTime{t: &t} +} + +// NewOptionalDateTime returns a new *DateTime. If the given time.Time +// is nil, nil will be returned. +func NewOptionalDateTime(t *time.Time) *DateTime { + if t == nil { + return nil + } + return &DateTime{t: t} +} + +// Time returns the DateTime's underlying time, if any. If the +// date-time is nil, the zero value is returned. +func (d *DateTime) Time() time.Time { + if d == nil || d.t == nil { + return time.Time{} + } + return *d.t +} + +// TimePtr returns a pointer to the DateTime's underlying time.Time, if any. +func (d *DateTime) TimePtr() *time.Time { + if d == nil || d.t == nil { + return nil + } + if d.t.IsZero() { + return nil + } + return d.t +} + +func (d *DateTime) MarshalJSON() ([]byte, error) { + if d == nil || d.t == nil { + return nil, nil + } + return json.Marshal(d.t.Format(time.RFC3339)) +} + +func (d *DateTime) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + parsedTime, err := time.Parse(time.RFC3339, raw) + if err != nil { + return err + } + + *d = DateTime{t: &parsedTime} + return nil +} diff --git a/seed/go-model/inline-types/snippet-templates.json b/seed/go-model/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/go-model/inline-types/snippet.json b/seed/go-model/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/go-model/inline-types/types.go b/seed/go-model/inline-types/types.go new file mode 100644 index 00000000000..44f1e804e10 --- /dev/null +++ b/seed/go-model/inline-types/types.go @@ -0,0 +1,385 @@ +// This file was auto-generated by Fern from our API Definition. + +package object + +import ( + json "encoding/json" + fmt "fmt" + internal "github.com/inline-types/fern/internal" +) + +type InlineEnum string + +const ( + InlineEnumSunny InlineEnum = "SUNNY" + InlineEnumCloudy InlineEnum = "CLOUDY" + InlineEnumRaining InlineEnum = "RAINING" + InlineEnumSnowing InlineEnum = "SNOWING" +) + +func NewInlineEnumFromString(s string) (InlineEnum, error) { + switch s { + case "SUNNY": + return InlineEnumSunny, nil + case "CLOUDY": + return InlineEnumCloudy, nil + case "RAINING": + return InlineEnumRaining, nil + case "SNOWING": + return InlineEnumSnowing, nil + } + var t InlineEnum + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (i InlineEnum) Ptr() *InlineEnum { + return &i +} + +type InlineType1 struct { + Foo string `json:"foo" url:"foo"` + Bar *NestedInlineType1 `json:"bar,omitempty" url:"bar,omitempty"` + + extraProperties map[string]interface{} +} + +func (i *InlineType1) GetFoo() string { + if i == nil { + return "" + } + return i.Foo +} + +func (i *InlineType1) GetBar() *NestedInlineType1 { + if i == nil { + return nil + } + return i.Bar +} + +func (i *InlineType1) GetExtraProperties() map[string]interface{} { + return i.extraProperties +} + +func (i *InlineType1) UnmarshalJSON(data []byte) error { + type unmarshaler InlineType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *i = InlineType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *i) + if err != nil { + return err + } + i.extraProperties = extraProperties + return nil +} + +func (i *InlineType1) String() string { + if value, err := internal.StringifyJSON(i); err == nil { + return value + } + return fmt.Sprintf("%#v", i) +} + +type InlineType2 struct { + Baz string `json:"baz" url:"baz"` + + extraProperties map[string]interface{} +} + +func (i *InlineType2) GetBaz() string { + if i == nil { + return "" + } + return i.Baz +} + +func (i *InlineType2) GetExtraProperties() map[string]interface{} { + return i.extraProperties +} + +func (i *InlineType2) UnmarshalJSON(data []byte) error { + type unmarshaler InlineType2 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *i = InlineType2(value) + extraProperties, err := internal.ExtractExtraProperties(data, *i) + if err != nil { + return err + } + i.extraProperties = extraProperties + return nil +} + +func (i *InlineType2) String() string { + if value, err := internal.StringifyJSON(i); err == nil { + return value + } + return fmt.Sprintf("%#v", i) +} + +type InlinedDiscriminatedUnion1 struct { + Type string + Type1 *InlineType1 + Type2 *InlineType2 +} + +func NewInlinedDiscriminatedUnion1FromType1(value *InlineType1) *InlinedDiscriminatedUnion1 { + return &InlinedDiscriminatedUnion1{Type: "type1", Type1: value} +} + +func NewInlinedDiscriminatedUnion1FromType2(value *InlineType2) *InlinedDiscriminatedUnion1 { + return &InlinedDiscriminatedUnion1{Type: "type2", Type2: value} +} + +func (i *InlinedDiscriminatedUnion1) GetType() string { + if i == nil { + return "" + } + return i.Type +} + +func (i *InlinedDiscriminatedUnion1) GetType1() *InlineType1 { + if i == nil { + return nil + } + return i.Type1 +} + +func (i *InlinedDiscriminatedUnion1) GetType2() *InlineType2 { + if i == nil { + return nil + } + return i.Type2 +} + +func (i *InlinedDiscriminatedUnion1) UnmarshalJSON(data []byte) error { + var unmarshaler struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &unmarshaler); err != nil { + return err + } + i.Type = unmarshaler.Type + if unmarshaler.Type == "" { + return fmt.Errorf("%T did not include discriminant type", i) + } + switch unmarshaler.Type { + case "type1": + value := new(InlineType1) + if err := json.Unmarshal(data, &value); err != nil { + return err + } + i.Type1 = value + case "type2": + value := new(InlineType2) + if err := json.Unmarshal(data, &value); err != nil { + return err + } + i.Type2 = value + } + return nil +} + +func (i InlinedDiscriminatedUnion1) MarshalJSON() ([]byte, error) { + switch i.Type { + default: + return nil, fmt.Errorf("invalid type %s in %T", i.Type, i) + case "type1": + return internal.MarshalJSONWithExtraProperty(i.Type1, "type", "type1") + case "type2": + return internal.MarshalJSONWithExtraProperty(i.Type2, "type", "type2") + } +} + +type InlinedDiscriminatedUnion1Visitor interface { + VisitType1(*InlineType1) error + VisitType2(*InlineType2) error +} + +func (i *InlinedDiscriminatedUnion1) Accept(visitor InlinedDiscriminatedUnion1Visitor) error { + switch i.Type { + default: + return fmt.Errorf("invalid type %s in %T", i.Type, i) + case "type1": + return visitor.VisitType1(i.Type1) + case "type2": + return visitor.VisitType2(i.Type2) + } +} + +type InlinedUndiscriminatedUnion1 struct { + InlineType1 *InlineType1 + InlineType2 *InlineType2 + + typ string +} + +func NewInlinedUndiscriminatedUnion1FromInlineType1(value *InlineType1) *InlinedUndiscriminatedUnion1 { + return &InlinedUndiscriminatedUnion1{typ: "InlineType1", InlineType1: value} +} + +func NewInlinedUndiscriminatedUnion1FromInlineType2(value *InlineType2) *InlinedUndiscriminatedUnion1 { + return &InlinedUndiscriminatedUnion1{typ: "InlineType2", InlineType2: value} +} + +func (i *InlinedUndiscriminatedUnion1) GetInlineType1() *InlineType1 { + if i == nil { + return nil + } + return i.InlineType1 +} + +func (i *InlinedUndiscriminatedUnion1) GetInlineType2() *InlineType2 { + if i == nil { + return nil + } + return i.InlineType2 +} + +func (i *InlinedUndiscriminatedUnion1) UnmarshalJSON(data []byte) error { + valueInlineType1 := new(InlineType1) + if err := json.Unmarshal(data, &valueInlineType1); err == nil { + i.typ = "InlineType1" + i.InlineType1 = valueInlineType1 + return nil + } + valueInlineType2 := new(InlineType2) + if err := json.Unmarshal(data, &valueInlineType2); err == nil { + i.typ = "InlineType2" + i.InlineType2 = valueInlineType2 + return nil + } + return fmt.Errorf("%s cannot be deserialized as a %T", data, i) +} + +func (i InlinedUndiscriminatedUnion1) MarshalJSON() ([]byte, error) { + if i.typ == "InlineType1" || i.InlineType1 != nil { + return json.Marshal(i.InlineType1) + } + if i.typ == "InlineType2" || i.InlineType2 != nil { + return json.Marshal(i.InlineType2) + } + return nil, fmt.Errorf("type %T does not include a non-empty union type", i) +} + +type InlinedUndiscriminatedUnion1Visitor interface { + VisitInlineType1(*InlineType1) error + VisitInlineType2(*InlineType2) error +} + +func (i *InlinedUndiscriminatedUnion1) Accept(visitor InlinedUndiscriminatedUnion1Visitor) error { + if i.typ == "InlineType1" || i.InlineType1 != nil { + return visitor.VisitInlineType1(i.InlineType1) + } + if i.typ == "InlineType2" || i.InlineType2 != nil { + return visitor.VisitInlineType2(i.InlineType2) + } + return fmt.Errorf("type %T does not include a non-empty union type", i) +} + +type NestedInlineType1 struct { + Foo string `json:"foo" url:"foo"` + Bar string `json:"bar" url:"bar"` + MyEnum InlineEnum `json:"myEnum" url:"myEnum"` + + extraProperties map[string]interface{} +} + +func (n *NestedInlineType1) GetFoo() string { + if n == nil { + return "" + } + return n.Foo +} + +func (n *NestedInlineType1) GetBar() string { + if n == nil { + return "" + } + return n.Bar +} + +func (n *NestedInlineType1) GetMyEnum() InlineEnum { + if n == nil { + return "" + } + return n.MyEnum +} + +func (n *NestedInlineType1) GetExtraProperties() map[string]interface{} { + return n.extraProperties +} + +func (n *NestedInlineType1) UnmarshalJSON(data []byte) error { + type unmarshaler NestedInlineType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *n = NestedInlineType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *n) + if err != nil { + return err + } + n.extraProperties = extraProperties + return nil +} + +func (n *NestedInlineType1) String() string { + if value, err := internal.StringifyJSON(n); err == nil { + return value + } + return fmt.Sprintf("%#v", n) +} + +type RootType1 struct { + Foo string `json:"foo" url:"foo"` + Bar *InlineType1 `json:"bar,omitempty" url:"bar,omitempty"` + + extraProperties map[string]interface{} +} + +func (r *RootType1) GetFoo() string { + if r == nil { + return "" + } + return r.Foo +} + +func (r *RootType1) GetBar() *InlineType1 { + if r == nil { + return nil + } + return r.Bar +} + +func (r *RootType1) GetExtraProperties() map[string]interface{} { + return r.extraProperties +} + +func (r *RootType1) UnmarshalJSON(data []byte) error { + type unmarshaler RootType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *r = RootType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *r) + if err != nil { + return err + } + r.extraProperties = extraProperties + return nil +} + +func (r *RootType1) String() string { + if value, err := internal.StringifyJSON(r); err == nil { + return value + } + return fmt.Sprintf("%#v", r) +} diff --git a/seed/go-sdk/inline-types/.github/workflows/ci.yml b/seed/go-sdk/inline-types/.github/workflows/ci.yml new file mode 100644 index 00000000000..d4c0a5dcd95 --- /dev/null +++ b/seed/go-sdk/inline-types/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Compile + run: go build ./... + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Test + run: go test ./... diff --git a/seed/go-sdk/inline-types/.mock/definition/__package__.yml b/seed/go-sdk/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/go-sdk/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/go-sdk/inline-types/.mock/definition/api.yml b/seed/go-sdk/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/go-sdk/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/go-sdk/inline-types/.mock/fern.config.json b/seed/go-sdk/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/go-sdk/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/go-sdk/inline-types/.mock/generators.yml b/seed/go-sdk/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/go-sdk/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/go-sdk/inline-types/client/client.go b/seed/go-sdk/inline-types/client/client.go new file mode 100644 index 00000000000..ccf71df33e5 --- /dev/null +++ b/seed/go-sdk/inline-types/client/client.go @@ -0,0 +1,70 @@ +// This file was auto-generated by Fern from our API Definition. + +package client + +import ( + context "context" + fern "github.com/inline-types/fern" + core "github.com/inline-types/fern/core" + internal "github.com/inline-types/fern/internal" + option "github.com/inline-types/fern/option" + http "net/http" +) + +type Client struct { + baseURL string + caller *internal.Caller + header http.Header +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + return &Client{ + baseURL: options.BaseURL, + caller: internal.NewCaller( + &internal.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), + } +} + +func (c *Client) GetRoot( + ctx context.Context, + request *fern.PostRootRequest, + opts ...option.RequestOption, +) (*fern.RootType1, error) { + options := core.NewRequestOptions(opts...) + baseURL := internal.ResolveBaseURL( + options.BaseURL, + c.baseURL, + "", + ) + endpointURL := baseURL + "/root/root" + headers := internal.MergeHeaders( + c.header.Clone(), + options.ToHeader(), + ) + headers.Set("Content-Type", "application/json") + + var response *fern.RootType1 + if err := c.caller.Call( + ctx, + &internal.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + Headers: headers, + MaxAttempts: options.MaxAttempts, + BodyProperties: options.BodyProperties, + QueryParameters: options.QueryParameters, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} diff --git a/seed/go-sdk/inline-types/client/client_test.go b/seed/go-sdk/inline-types/client/client_test.go new file mode 100644 index 00000000000..73ba53c8db4 --- /dev/null +++ b/seed/go-sdk/inline-types/client/client_test.go @@ -0,0 +1,45 @@ +// This file was auto-generated by Fern from our API Definition. + +package client + +import ( + option "github.com/inline-types/fern/option" + assert "github.com/stretchr/testify/assert" + http "net/http" + testing "testing" + time "time" +) + +func TestNewClient(t *testing.T) { + t.Run("default", func(t *testing.T) { + c := NewClient() + assert.Empty(t, c.baseURL) + }) + + t.Run("base url", func(t *testing.T) { + c := NewClient( + option.WithBaseURL("test.co"), + ) + assert.Equal(t, "test.co", c.baseURL) + }) + + t.Run("http client", func(t *testing.T) { + httpClient := &http.Client{ + Timeout: 5 * time.Second, + } + c := NewClient( + option.WithHTTPClient(httpClient), + ) + assert.Empty(t, c.baseURL) + }) + + t.Run("http header", func(t *testing.T) { + header := make(http.Header) + header.Set("X-API-Tenancy", "test") + c := NewClient( + option.WithHTTPHeader(header), + ) + assert.Empty(t, c.baseURL) + assert.Equal(t, "test", c.header.Get("X-API-Tenancy")) + }) +} diff --git a/seed/go-sdk/inline-types/core/api_error.go b/seed/go-sdk/inline-types/core/api_error.go new file mode 100644 index 00000000000..dc4190ca1cd --- /dev/null +++ b/seed/go-sdk/inline-types/core/api_error.go @@ -0,0 +1,42 @@ +package core + +import "fmt" + +// APIError is a lightweight wrapper around the standard error +// interface that preserves the status code from the RPC, if any. +type APIError struct { + err error + + StatusCode int `json:"-"` +} + +// NewAPIError constructs a new API error. +func NewAPIError(statusCode int, err error) *APIError { + return &APIError{ + err: err, + StatusCode: statusCode, + } +} + +// Unwrap returns the underlying error. This also makes the error compatible +// with errors.As and errors.Is. +func (a *APIError) Unwrap() error { + if a == nil { + return nil + } + return a.err +} + +// Error returns the API error's message. +func (a *APIError) Error() string { + if a == nil || (a.err == nil && a.StatusCode == 0) { + return "" + } + if a.err == nil { + return fmt.Sprintf("%d", a.StatusCode) + } + if a.StatusCode == 0 { + return a.err.Error() + } + return fmt.Sprintf("%d: %s", a.StatusCode, a.err.Error()) +} diff --git a/seed/go-sdk/inline-types/core/http.go b/seed/go-sdk/inline-types/core/http.go new file mode 100644 index 00000000000..b553350b84e --- /dev/null +++ b/seed/go-sdk/inline-types/core/http.go @@ -0,0 +1,8 @@ +package core + +import "net/http" + +// HTTPClient is an interface for a subset of the *http.Client. +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} diff --git a/seed/go-sdk/inline-types/core/request_option.go b/seed/go-sdk/inline-types/core/request_option.go new file mode 100644 index 00000000000..59b90a18fb8 --- /dev/null +++ b/seed/go-sdk/inline-types/core/request_option.go @@ -0,0 +1,108 @@ +// This file was auto-generated by Fern from our API Definition. + +package core + +import ( + http "net/http" + url "net/url" +) + +// RequestOption adapts the behavior of the client or an individual request. +type RequestOption interface { + applyRequestOptions(*RequestOptions) +} + +// RequestOptions defines all of the possible request options. +// +// This type is primarily used by the generated code and is not meant +// to be used directly; use the option package instead. +type RequestOptions struct { + BaseURL string + HTTPClient HTTPClient + HTTPHeader http.Header + BodyProperties map[string]interface{} + QueryParameters url.Values + MaxAttempts uint +} + +// NewRequestOptions returns a new *RequestOptions value. +// +// This function is primarily used by the generated code and is not meant +// to be used directly; use RequestOption instead. +func NewRequestOptions(opts ...RequestOption) *RequestOptions { + options := &RequestOptions{ + HTTPHeader: make(http.Header), + BodyProperties: make(map[string]interface{}), + QueryParameters: make(url.Values), + } + for _, opt := range opts { + opt.applyRequestOptions(options) + } + return options +} + +// ToHeader maps the configured request options into a http.Header used +// for the request(s). +func (r *RequestOptions) ToHeader() http.Header { return r.cloneHeader() } + +func (r *RequestOptions) cloneHeader() http.Header { + headers := r.HTTPHeader.Clone() + headers.Set("X-Fern-Language", "Go") + headers.Set("X-Fern-SDK-Name", "github.com/inline-types/fern") + headers.Set("X-Fern-SDK-Version", "0.0.1") + return headers +} + +// BaseURLOption implements the RequestOption interface. +type BaseURLOption struct { + BaseURL string +} + +func (b *BaseURLOption) applyRequestOptions(opts *RequestOptions) { + opts.BaseURL = b.BaseURL +} + +// HTTPClientOption implements the RequestOption interface. +type HTTPClientOption struct { + HTTPClient HTTPClient +} + +func (h *HTTPClientOption) applyRequestOptions(opts *RequestOptions) { + opts.HTTPClient = h.HTTPClient +} + +// HTTPHeaderOption implements the RequestOption interface. +type HTTPHeaderOption struct { + HTTPHeader http.Header +} + +func (h *HTTPHeaderOption) applyRequestOptions(opts *RequestOptions) { + opts.HTTPHeader = h.HTTPHeader +} + +// BodyPropertiesOption implements the RequestOption interface. +type BodyPropertiesOption struct { + BodyProperties map[string]interface{} +} + +func (b *BodyPropertiesOption) applyRequestOptions(opts *RequestOptions) { + opts.BodyProperties = b.BodyProperties +} + +// QueryParametersOption implements the RequestOption interface. +type QueryParametersOption struct { + QueryParameters url.Values +} + +func (q *QueryParametersOption) applyRequestOptions(opts *RequestOptions) { + opts.QueryParameters = q.QueryParameters +} + +// MaxAttemptsOption implements the RequestOption interface. +type MaxAttemptsOption struct { + MaxAttempts uint +} + +func (m *MaxAttemptsOption) applyRequestOptions(opts *RequestOptions) { + opts.MaxAttempts = m.MaxAttempts +} diff --git a/seed/go-sdk/inline-types/dynamic-snippets/example0/snippet.go b/seed/go-sdk/inline-types/dynamic-snippets/example0/snippet.go new file mode 100644 index 00000000000..3dc762bbee3 --- /dev/null +++ b/seed/go-sdk/inline-types/dynamic-snippets/example0/snippet.go @@ -0,0 +1,25 @@ +package example + +import ( + client "github.com/inline-types/fern/client" + context "context" + fern "github.com/inline-types/fern" +) + +func do() () { + client := client.NewClient() + client.GetRoot( + context.TODO(), + &fern.PostRootRequest{ + Bar: &fern.InlineType1{ + Foo: "foo", + Bar: &fern.NestedInlineType1{ + Foo: "foo", + Bar: "bar", + MyEnum: fern.InlineEnumSunny, + }, + }, + Foo: "foo", + }, + ) +} diff --git a/seed/go-sdk/inline-types/file_param.go b/seed/go-sdk/inline-types/file_param.go new file mode 100644 index 00000000000..33abc01dbcd --- /dev/null +++ b/seed/go-sdk/inline-types/file_param.go @@ -0,0 +1,41 @@ +package object + +import ( + "io" +) + +// FileParam is a file type suitable for multipart/form-data uploads. +type FileParam struct { + io.Reader + filename string + contentType string +} + +// FileParamOption adapts the behavior of the FileParam. No options are +// implemented yet, but this interface allows for future extensibility. +type FileParamOption interface { + apply() +} + +// NewFileParam returns a *FileParam type suitable for multipart/form-data uploads. All file +// upload endpoints accept a simple io.Reader, which is usually created by opening a file +// via os.Open. +// +// However, some endpoints require additional metadata about the file such as a specific +// Content-Type or custom filename. FileParam makes it easier to create the correct type +// signature for these endpoints. +func NewFileParam( + reader io.Reader, + filename string, + contentType string, + opts ...FileParamOption, +) *FileParam { + return &FileParam{ + Reader: reader, + filename: filename, + contentType: contentType, + } +} + +func (f *FileParam) Name() string { return f.filename } +func (f *FileParam) ContentType() string { return f.contentType } diff --git a/seed/go-sdk/inline-types/go.mod b/seed/go-sdk/inline-types/go.mod new file mode 100644 index 00000000000..744d58e186e --- /dev/null +++ b/seed/go-sdk/inline-types/go.mod @@ -0,0 +1,9 @@ +module github.com/inline-types/fern + +go 1.13 + +require ( + github.com/google/uuid v1.4.0 + github.com/stretchr/testify v1.7.0 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/seed/go-sdk/inline-types/go.sum b/seed/go-sdk/inline-types/go.sum new file mode 100644 index 00000000000..b3766d4366b --- /dev/null +++ b/seed/go-sdk/inline-types/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/seed/go-sdk/inline-types/internal/caller.go b/seed/go-sdk/inline-types/internal/caller.go new file mode 100644 index 00000000000..213837b3617 --- /dev/null +++ b/seed/go-sdk/inline-types/internal/caller.go @@ -0,0 +1,238 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + + "github.com/inline-types/fern/core" +) + +const ( + // contentType specifies the JSON Content-Type header value. + contentType = "application/json" + contentTypeHeader = "Content-Type" +) + +// Caller calls APIs and deserializes their response, if any. +type Caller struct { + client core.HTTPClient + retrier *Retrier +} + +// CallerParams represents the parameters used to constrcut a new *Caller. +type CallerParams struct { + Client core.HTTPClient + MaxAttempts uint +} + +// NewCaller returns a new *Caller backed by the given parameters. +func NewCaller(params *CallerParams) *Caller { + var httpClient core.HTTPClient = http.DefaultClient + if params.Client != nil { + httpClient = params.Client + } + var retryOptions []RetryOption + if params.MaxAttempts > 0 { + retryOptions = append(retryOptions, WithMaxAttempts(params.MaxAttempts)) + } + return &Caller{ + client: httpClient, + retrier: NewRetrier(retryOptions...), + } +} + +// CallParams represents the parameters used to issue an API call. +type CallParams struct { + URL string + Method string + MaxAttempts uint + Headers http.Header + BodyProperties map[string]interface{} + QueryParameters url.Values + Client core.HTTPClient + Request interface{} + Response interface{} + ResponseIsOptional bool + ErrorDecoder ErrorDecoder +} + +// Call issues an API call according to the given call parameters. +func (c *Caller) Call(ctx context.Context, params *CallParams) error { + url := buildURL(params.URL, params.QueryParameters) + req, err := newRequest( + ctx, + url, + params.Method, + params.Headers, + params.Request, + params.BodyProperties, + ) + if err != nil { + return err + } + + // If the call has been cancelled, don't issue the request. + if err := ctx.Err(); err != nil { + return err + } + + client := c.client + if params.Client != nil { + // Use the HTTP client scoped to the request. + client = params.Client + } + + var retryOptions []RetryOption + if params.MaxAttempts > 0 { + retryOptions = append(retryOptions, WithMaxAttempts(params.MaxAttempts)) + } + + resp, err := c.retrier.Run( + client.Do, + req, + params.ErrorDecoder, + retryOptions..., + ) + if err != nil { + return err + } + + // Close the response body after we're done. + defer resp.Body.Close() + + // Check if the call was cancelled before we return the error + // associated with the call and/or unmarshal the response data. + if err := ctx.Err(); err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return decodeError(resp, params.ErrorDecoder) + } + + // Mutate the response parameter in-place. + if params.Response != nil { + if writer, ok := params.Response.(io.Writer); ok { + _, err = io.Copy(writer, resp.Body) + } else { + err = json.NewDecoder(resp.Body).Decode(params.Response) + } + if err != nil { + if err == io.EOF { + if params.ResponseIsOptional { + // The response is optional, so we should ignore the + // io.EOF error + return nil + } + return fmt.Errorf("expected a %T response, but the server responded with nothing", params.Response) + } + return err + } + } + + return nil +} + +// buildURL constructs the final URL by appending the given query parameters (if any). +func buildURL( + url string, + queryParameters url.Values, +) string { + if len(queryParameters) == 0 { + return url + } + if strings.ContainsRune(url, '?') { + url += "&" + } else { + url += "?" + } + url += queryParameters.Encode() + return url +} + +// newRequest returns a new *http.Request with all of the fields +// required to issue the call. +func newRequest( + ctx context.Context, + url string, + method string, + endpointHeaders http.Header, + request interface{}, + bodyProperties map[string]interface{}, +) (*http.Request, error) { + requestBody, err := newRequestBody(request, bodyProperties) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, url, requestBody) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header.Set(contentTypeHeader, contentType) + for name, values := range endpointHeaders { + req.Header[name] = values + } + return req, nil +} + +// newRequestBody returns a new io.Reader that represents the HTTP request body. +func newRequestBody(request interface{}, bodyProperties map[string]interface{}) (io.Reader, error) { + if isNil(request) { + if len(bodyProperties) == 0 { + return nil, nil + } + requestBytes, err := json.Marshal(bodyProperties) + if err != nil { + return nil, err + } + return bytes.NewReader(requestBytes), nil + } + if body, ok := request.(io.Reader); ok { + return body, nil + } + requestBytes, err := MarshalJSONWithExtraProperties(request, bodyProperties) + if err != nil { + return nil, err + } + return bytes.NewReader(requestBytes), nil +} + +// decodeError decodes the error from the given HTTP response. Note that +// it's the caller's responsibility to close the response body. +func decodeError(response *http.Response, errorDecoder ErrorDecoder) error { + if errorDecoder != nil { + // This endpoint has custom errors, so we'll + // attempt to unmarshal the error into a structured + // type based on the status code. + return errorDecoder(response.StatusCode, response.Body) + } + // This endpoint doesn't have any custom error + // types, so we just read the body as-is, and + // put it into a normal error. + bytes, err := io.ReadAll(response.Body) + if err != nil && err != io.EOF { + return err + } + if err == io.EOF { + // The error didn't have a response body, + // so all we can do is return an error + // with the status code. + return core.NewAPIError(response.StatusCode, nil) + } + return core.NewAPIError(response.StatusCode, errors.New(string(bytes))) +} + +// isNil is used to determine if the request value is equal to nil (i.e. an interface +// value that holds a nil concrete value is itself non-nil). +func isNil(value interface{}) bool { + return value == nil || reflect.ValueOf(value).IsNil() +} diff --git a/seed/go-sdk/inline-types/internal/caller_test.go b/seed/go-sdk/inline-types/internal/caller_test.go new file mode 100644 index 00000000000..c7558afaef8 --- /dev/null +++ b/seed/go-sdk/inline-types/internal/caller_test.go @@ -0,0 +1,391 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/inline-types/fern/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCase represents a single test case. +type TestCase struct { + description string + + // Server-side assertions. + givePathSuffix string + giveMethod string + giveResponseIsOptional bool + giveHeader http.Header + giveErrorDecoder ErrorDecoder + giveRequest *Request + giveQueryParams url.Values + giveBodyProperties map[string]interface{} + + // Client-side assertions. + wantResponse *Response + wantError error +} + +// Request a simple request body. +type Request struct { + Id string `json:"id"` +} + +// Response a simple response body. +type Response struct { + Id string `json:"id"` + ExtraBodyProperties map[string]interface{} `json:"extraBodyProperties,omitempty"` + QueryParameters url.Values `json:"queryParameters,omitempty"` +} + +// NotFoundError represents a 404. +type NotFoundError struct { + *core.APIError + + Message string `json:"message"` +} + +func TestCall(t *testing.T) { + tests := []*TestCase{ + { + description: "GET success", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &Request{ + Id: "123", + }, + wantResponse: &Response{ + Id: "123", + }, + }, + { + description: "GET success with query", + givePathSuffix: "?limit=1", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &Request{ + Id: "123", + }, + wantResponse: &Response{ + Id: "123", + QueryParameters: url.Values{ + "limit": []string{"1"}, + }, + }, + }, + { + description: "GET not found", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"fail"}, + }, + giveRequest: &Request{ + Id: strconv.Itoa(http.StatusNotFound), + }, + giveErrorDecoder: newTestErrorDecoder(t), + wantError: &NotFoundError{ + APIError: core.NewAPIError( + http.StatusNotFound, + errors.New(`{"message":"ID \"404\" not found"}`), + ), + }, + }, + { + description: "POST empty body", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"fail"}, + }, + giveRequest: nil, + wantError: core.NewAPIError( + http.StatusBadRequest, + errors.New("invalid request"), + ), + }, + { + description: "POST optional response", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &Request{ + Id: "123", + }, + giveResponseIsOptional: true, + }, + { + description: "POST API error", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"fail"}, + }, + giveRequest: &Request{ + Id: strconv.Itoa(http.StatusInternalServerError), + }, + wantError: core.NewAPIError( + http.StatusInternalServerError, + errors.New("failed to process request"), + ), + }, + { + description: "POST extra properties", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: new(Request), + giveBodyProperties: map[string]interface{}{ + "key": "value", + }, + wantResponse: &Response{ + ExtraBodyProperties: map[string]interface{}{ + "key": "value", + }, + }, + }, + { + description: "GET extra query parameters", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveQueryParams: url.Values{ + "extra": []string{"true"}, + }, + giveRequest: &Request{ + Id: "123", + }, + wantResponse: &Response{ + Id: "123", + QueryParameters: url.Values{ + "extra": []string{"true"}, + }, + }, + }, + { + description: "GET merge extra query parameters", + givePathSuffix: "?limit=1", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &Request{ + Id: "123", + }, + giveQueryParams: url.Values{ + "extra": []string{"true"}, + }, + wantResponse: &Response{ + Id: "123", + QueryParameters: url.Values{ + "limit": []string{"1"}, + "extra": []string{"true"}, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + var ( + server = newTestServer(t, test) + client = server.Client() + ) + caller := NewCaller( + &CallerParams{ + Client: client, + }, + ) + var response *Response + err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL + test.givePathSuffix, + Method: test.giveMethod, + Headers: test.giveHeader, + BodyProperties: test.giveBodyProperties, + QueryParameters: test.giveQueryParams, + Request: test.giveRequest, + Response: &response, + ResponseIsOptional: test.giveResponseIsOptional, + ErrorDecoder: test.giveErrorDecoder, + }, + ) + if test.wantError != nil { + assert.EqualError(t, err, test.wantError.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, test.wantResponse, response) + }) + } +} + +func TestMergeHeaders(t *testing.T) { + t.Run("both empty", func(t *testing.T) { + merged := MergeHeaders(make(http.Header), make(http.Header)) + assert.Empty(t, merged) + }) + + t.Run("empty left", func(t *testing.T) { + left := make(http.Header) + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, "0.0.1", merged.Get("X-API-Version")) + }) + + t.Run("empty right", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Version", "0.0.1") + + right := make(http.Header) + + merged := MergeHeaders(left, right) + assert.Equal(t, "0.0.1", merged.Get("X-API-Version")) + }) + + t.Run("single value override", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Version", "0.0.0") + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"0.0.1"}, merged.Values("X-API-Version")) + }) + + t.Run("multiple value override", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Versions", "0.0.0") + + right := make(http.Header) + right.Add("X-API-Versions", "0.0.1") + right.Add("X-API-Versions", "0.0.2") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"0.0.1", "0.0.2"}, merged.Values("X-API-Versions")) + }) + + t.Run("disjoint merge", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Tenancy", "test") + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"test"}, merged.Values("X-API-Tenancy")) + assert.Equal(t, []string{"0.0.1"}, merged.Values("X-API-Version")) + }) +} + +// newTestServer returns a new *httptest.Server configured with the +// given test parameters. +func newTestServer(t *testing.T, tc *TestCase) *httptest.Server { + return httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.giveMethod, r.Method) + assert.Equal(t, contentType, r.Header.Get(contentTypeHeader)) + for header, value := range tc.giveHeader { + assert.Equal(t, value, r.Header.Values(header)) + } + + request := new(Request) + + bytes, err := io.ReadAll(r.Body) + if tc.giveRequest == nil { + require.Empty(t, bytes) + w.WriteHeader(http.StatusBadRequest) + _, err = w.Write([]byte("invalid request")) + require.NoError(t, err) + return + } + require.NoError(t, err) + require.NoError(t, json.Unmarshal(bytes, request)) + + switch request.Id { + case strconv.Itoa(http.StatusNotFound): + notFoundError := &NotFoundError{ + APIError: &core.APIError{ + StatusCode: http.StatusNotFound, + }, + Message: fmt.Sprintf("ID %q not found", request.Id), + } + bytes, err = json.Marshal(notFoundError) + require.NoError(t, err) + + w.WriteHeader(http.StatusNotFound) + _, err = w.Write(bytes) + require.NoError(t, err) + return + + case strconv.Itoa(http.StatusInternalServerError): + w.WriteHeader(http.StatusInternalServerError) + _, err = w.Write([]byte("failed to process request")) + require.NoError(t, err) + return + } + + if tc.giveResponseIsOptional { + w.WriteHeader(http.StatusOK) + return + } + + extraBodyProperties := make(map[string]interface{}) + require.NoError(t, json.Unmarshal(bytes, &extraBodyProperties)) + delete(extraBodyProperties, "id") + + response := &Response{ + Id: request.Id, + ExtraBodyProperties: extraBodyProperties, + QueryParameters: r.URL.Query(), + } + bytes, err = json.Marshal(response) + require.NoError(t, err) + + _, err = w.Write(bytes) + require.NoError(t, err) + }, + ), + ) +} + +// newTestErrorDecoder returns an error decoder suitable for tests. +func newTestErrorDecoder(t *testing.T) func(int, io.Reader) error { + return func(statusCode int, body io.Reader) error { + raw, err := io.ReadAll(body) + require.NoError(t, err) + + var ( + apiError = core.NewAPIError(statusCode, errors.New(string(raw))) + decoder = json.NewDecoder(bytes.NewReader(raw)) + ) + if statusCode == http.StatusNotFound { + value := new(NotFoundError) + value.APIError = apiError + require.NoError(t, decoder.Decode(value)) + + return value + } + return apiError + } +} diff --git a/seed/go-sdk/inline-types/internal/error_decoder.go b/seed/go-sdk/inline-types/internal/error_decoder.go new file mode 100644 index 00000000000..1321c464c24 --- /dev/null +++ b/seed/go-sdk/inline-types/internal/error_decoder.go @@ -0,0 +1,45 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/inline-types/fern/core" +) + +// ErrorDecoder decodes *http.Response errors and returns a +// typed API error (e.g. *core.APIError). +type ErrorDecoder func(statusCode int, body io.Reader) error + +// ErrorCodes maps HTTP status codes to error constructors. +type ErrorCodes map[int]func(*core.APIError) error + +// NewErrorDecoder returns a new ErrorDecoder backed by the given error codes. +func NewErrorDecoder(errorCodes ErrorCodes) ErrorDecoder { + return func(statusCode int, body io.Reader) error { + raw, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("failed to read error from response body: %w", err) + } + apiError := core.NewAPIError( + statusCode, + errors.New(string(raw)), + ) + newErrorFunc, ok := errorCodes[statusCode] + if !ok { + // This status code isn't recognized, so we return + // the API error as-is. + return apiError + } + customError := newErrorFunc(apiError) + if err := json.NewDecoder(bytes.NewReader(raw)).Decode(customError); err != nil { + // If we fail to decode the error, we return the + // API error as-is. + return apiError + } + return customError + } +} diff --git a/seed/go-sdk/inline-types/internal/error_decoder_test.go b/seed/go-sdk/inline-types/internal/error_decoder_test.go new file mode 100644 index 00000000000..2bc84bfa21d --- /dev/null +++ b/seed/go-sdk/inline-types/internal/error_decoder_test.go @@ -0,0 +1,55 @@ +package internal + +import ( + "bytes" + "errors" + "net/http" + "testing" + + "github.com/inline-types/fern/core" + "github.com/stretchr/testify/assert" +) + +func TestErrorDecoder(t *testing.T) { + decoder := NewErrorDecoder( + ErrorCodes{ + http.StatusNotFound: func(apiError *core.APIError) error { + return &NotFoundError{APIError: apiError} + }, + }) + + tests := []struct { + description string + giveStatusCode int + giveBody string + wantError error + }{ + { + description: "unrecognized status code", + giveStatusCode: http.StatusInternalServerError, + giveBody: "Internal Server Error", + wantError: core.NewAPIError(http.StatusInternalServerError, errors.New("Internal Server Error")), + }, + { + description: "not found with valid JSON", + giveStatusCode: http.StatusNotFound, + giveBody: `{"message": "Resource not found"}`, + wantError: &NotFoundError{ + APIError: core.NewAPIError(http.StatusNotFound, errors.New(`{"message": "Resource not found"}`)), + Message: "Resource not found", + }, + }, + { + description: "not found with invalid JSON", + giveStatusCode: http.StatusNotFound, + giveBody: `Resource not found`, + wantError: core.NewAPIError(http.StatusNotFound, errors.New("Resource not found")), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + assert.Equal(t, tt.wantError, decoder(tt.giveStatusCode, bytes.NewReader([]byte(tt.giveBody)))) + }) + } +} diff --git a/seed/go-sdk/inline-types/internal/extra_properties.go b/seed/go-sdk/inline-types/internal/extra_properties.go new file mode 100644 index 00000000000..540c3fd89ee --- /dev/null +++ b/seed/go-sdk/inline-types/internal/extra_properties.go @@ -0,0 +1,141 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// MarshalJSONWithExtraProperty marshals the given value to JSON, including the extra property. +func MarshalJSONWithExtraProperty(marshaler interface{}, key string, value interface{}) ([]byte, error) { + return MarshalJSONWithExtraProperties(marshaler, map[string]interface{}{key: value}) +} + +// MarshalJSONWithExtraProperties marshals the given value to JSON, including any extra properties. +func MarshalJSONWithExtraProperties(marshaler interface{}, extraProperties map[string]interface{}) ([]byte, error) { + bytes, err := json.Marshal(marshaler) + if err != nil { + return nil, err + } + if len(extraProperties) == 0 { + return bytes, nil + } + keys, err := getKeys(marshaler) + if err != nil { + return nil, err + } + for _, key := range keys { + if _, ok := extraProperties[key]; ok { + return nil, fmt.Errorf("cannot add extra property %q because it is already defined on the type", key) + } + } + extraBytes, err := json.Marshal(extraProperties) + if err != nil { + return nil, err + } + if isEmptyJSON(bytes) { + if isEmptyJSON(extraBytes) { + return bytes, nil + } + return extraBytes, nil + } + result := bytes[:len(bytes)-1] + result = append(result, ',') + result = append(result, extraBytes[1:len(extraBytes)-1]...) + result = append(result, '}') + return result, nil +} + +// ExtractExtraProperties extracts any extra properties from the given value. +func ExtractExtraProperties(bytes []byte, value interface{}, exclude ...string) (map[string]interface{}, error) { + val := reflect.ValueOf(value) + for val.Kind() == reflect.Ptr { + if val.IsNil() { + return nil, fmt.Errorf("value must be non-nil to extract extra properties") + } + val = val.Elem() + } + if err := json.Unmarshal(bytes, &value); err != nil { + return nil, err + } + var extraProperties map[string]interface{} + if err := json.Unmarshal(bytes, &extraProperties); err != nil { + return nil, err + } + for i := 0; i < val.Type().NumField(); i++ { + key := jsonKey(val.Type().Field(i)) + if key == "" || key == "-" { + continue + } + delete(extraProperties, key) + } + for _, key := range exclude { + delete(extraProperties, key) + } + if len(extraProperties) == 0 { + return nil, nil + } + return extraProperties, nil +} + +// getKeys returns the keys associated with the given value. The value must be a +// a struct or a map with string keys. +func getKeys(value interface{}) ([]string, error) { + val := reflect.ValueOf(value) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if !val.IsValid() { + return nil, nil + } + switch val.Kind() { + case reflect.Struct: + return getKeysForStructType(val.Type()), nil + case reflect.Map: + var keys []string + if val.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("cannot extract keys from %T; only structs and maps with string keys are supported", value) + } + for _, key := range val.MapKeys() { + keys = append(keys, key.String()) + } + return keys, nil + default: + return nil, fmt.Errorf("cannot extract keys from %T; only structs and maps with string keys are supported", value) + } +} + +// getKeysForStructType returns all the keys associated with the given struct type, +// visiting embedded fields recursively. +func getKeysForStructType(structType reflect.Type) []string { + if structType.Kind() == reflect.Pointer { + structType = structType.Elem() + } + if structType.Kind() != reflect.Struct { + return nil + } + var keys []string + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + if field.Anonymous { + keys = append(keys, getKeysForStructType(field.Type)...) + continue + } + keys = append(keys, jsonKey(field)) + } + return keys +} + +// jsonKey returns the JSON key from the struct tag of the given field, +// excluding the omitempty flag (if any). +func jsonKey(field reflect.StructField) string { + return strings.TrimSuffix(field.Tag.Get("json"), ",omitempty") +} + +// isEmptyJSON returns true if the given data is empty, the empty JSON object, or +// an explicit null. +func isEmptyJSON(data []byte) bool { + return len(data) <= 2 || bytes.Equal(data, []byte("null")) +} diff --git a/seed/go-sdk/inline-types/internal/extra_properties_test.go b/seed/go-sdk/inline-types/internal/extra_properties_test.go new file mode 100644 index 00000000000..aa2510ee512 --- /dev/null +++ b/seed/go-sdk/inline-types/internal/extra_properties_test.go @@ -0,0 +1,228 @@ +package internal + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testMarshaler struct { + Name string `json:"name"` + BirthDate time.Time `json:"birthDate"` + CreatedAt time.Time `json:"created_at"` +} + +func (t *testMarshaler) MarshalJSON() ([]byte, error) { + type embed testMarshaler + var marshaler = struct { + embed + BirthDate string `json:"birthDate"` + CreatedAt string `json:"created_at"` + }{ + embed: embed(*t), + BirthDate: t.BirthDate.Format("2006-01-02"), + CreatedAt: t.CreatedAt.Format(time.RFC3339), + } + return MarshalJSONWithExtraProperty(marshaler, "type", "test") +} + +func TestMarshalJSONWithExtraProperties(t *testing.T) { + tests := []struct { + desc string + giveMarshaler interface{} + giveExtraProperties map[string]interface{} + wantBytes []byte + wantError string + }{ + { + desc: "invalid type", + giveMarshaler: []string{"invalid"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot extract keys from []string; only structs and maps with string keys are supported`, + }, + { + desc: "invalid key type", + giveMarshaler: map[int]interface{}{42: "value"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot extract keys from map[int]interface {}; only structs and maps with string keys are supported`, + }, + { + desc: "invalid map overwrite", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot add extra property "key" because it is already defined on the type`, + }, + { + desc: "invalid struct overwrite", + giveMarshaler: new(testMarshaler), + giveExtraProperties: map[string]interface{}{"birthDate": "2000-01-01"}, + wantError: `cannot add extra property "birthDate" because it is already defined on the type`, + }, + { + desc: "invalid struct overwrite embedded type", + giveMarshaler: new(testMarshaler), + giveExtraProperties: map[string]interface{}{"name": "bob"}, + wantError: `cannot add extra property "name" because it is already defined on the type`, + }, + { + desc: "nil", + giveMarshaler: nil, + giveExtraProperties: nil, + wantBytes: []byte(`null`), + }, + { + desc: "empty", + giveMarshaler: map[string]interface{}{}, + giveExtraProperties: map[string]interface{}{}, + wantBytes: []byte(`{}`), + }, + { + desc: "no extra properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "only extra properties", + giveMarshaler: map[string]interface{}{}, + giveExtraProperties: map[string]interface{}{"key": "value"}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "single extra property", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"extra": "property"}, + wantBytes: []byte(`{"key":"value","extra":"property"}`), + }, + { + desc: "multiple extra properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"one": 1, "two": 2}, + wantBytes: []byte(`{"key":"value","one":1,"two":2}`), + }, + { + desc: "nested properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{ + "user": map[string]interface{}{ + "age": 42, + "name": "alice", + }, + }, + wantBytes: []byte(`{"key":"value","user":{"age":42,"name":"alice"}}`), + }, + { + desc: "multiple nested properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{ + "metadata": map[string]interface{}{ + "ip": "127.0.0.1", + }, + "user": map[string]interface{}{ + "age": 42, + "name": "alice", + }, + }, + wantBytes: []byte(`{"key":"value","metadata":{"ip":"127.0.0.1"},"user":{"age":42,"name":"alice"}}`), + }, + { + desc: "custom marshaler", + giveMarshaler: &testMarshaler{ + Name: "alice", + BirthDate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + giveExtraProperties: map[string]interface{}{ + "extra": "property", + }, + wantBytes: []byte(`{"name":"alice","birthDate":"2000-01-01","created_at":"2024-01-01T00:00:00Z","type":"test","extra":"property"}`), + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + bytes, err := MarshalJSONWithExtraProperties(tt.giveMarshaler, tt.giveExtraProperties) + if tt.wantError != "" { + require.EqualError(t, err, tt.wantError) + assert.Nil(t, tt.wantBytes) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantBytes, bytes) + + value := make(map[string]interface{}) + require.NoError(t, json.Unmarshal(bytes, &value)) + }) + } +} + +func TestExtractExtraProperties(t *testing.T) { + t.Run("none", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice"}`), value) + require.NoError(t, err) + assert.Nil(t, extraProperties) + }) + + t.Run("non-nil pointer", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("nil pointer", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + var value *user + _, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + assert.EqualError(t, err, "value must be non-nil to extract extra properties") + }) + + t.Run("non-zero value", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("zero value", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + var value user + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("exclude", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value, "age") + require.NoError(t, err) + assert.Nil(t, extraProperties) + }) +} diff --git a/seed/go-sdk/inline-types/internal/http.go b/seed/go-sdk/inline-types/internal/http.go new file mode 100644 index 00000000000..768968bd621 --- /dev/null +++ b/seed/go-sdk/inline-types/internal/http.go @@ -0,0 +1,48 @@ +package internal + +import ( + "fmt" + "net/http" + "net/url" +) + +// HTTPClient is an interface for a subset of the *http.Client. +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +// ResolveBaseURL resolves the base URL from the given arguments, +// preferring the first non-empty value. +func ResolveBaseURL(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +// EncodeURL encodes the given arguments into the URL, escaping +// values as needed. +func EncodeURL(urlFormat string, args ...interface{}) string { + escapedArgs := make([]interface{}, 0, len(args)) + for _, arg := range args { + escapedArgs = append(escapedArgs, url.PathEscape(fmt.Sprintf("%v", arg))) + } + return fmt.Sprintf(urlFormat, escapedArgs...) +} + +// MergeHeaders merges the given headers together, where the right +// takes precedence over the left. +func MergeHeaders(left, right http.Header) http.Header { + for key, values := range right { + if len(values) > 1 { + left[key] = values + continue + } + if value := right.Get(key); value != "" { + left.Set(key, value) + } + } + return left +} diff --git a/seed/go-sdk/inline-types/internal/query.go b/seed/go-sdk/inline-types/internal/query.go new file mode 100644 index 00000000000..6129e71ffe5 --- /dev/null +++ b/seed/go-sdk/inline-types/internal/query.go @@ -0,0 +1,231 @@ +package internal + +import ( + "encoding/base64" + "fmt" + "net/url" + "reflect" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + bytesType = reflect.TypeOf([]byte{}) + queryEncoderType = reflect.TypeOf(new(QueryEncoder)).Elem() + timeType = reflect.TypeOf(time.Time{}) + uuidType = reflect.TypeOf(uuid.UUID{}) +) + +// QueryEncoder is an interface implemented by any type that wishes to encode +// itself into URL values in a non-standard way. +type QueryEncoder interface { + EncodeQueryValues(key string, v *url.Values) error +} + +// QueryValues encodes url.Values from request objects. +// +// Note: This type is inspired by Google's query encoding library, but +// supports far less customization and is tailored to fit this SDK's use case. +// +// Ref: https://github.com/google/go-querystring +func QueryValues(v interface{}) (url.Values, error) { + values := make(url.Values) + val := reflect.ValueOf(v) + for val.Kind() == reflect.Ptr { + if val.IsNil() { + return values, nil + } + val = val.Elem() + } + + if v == nil { + return values, nil + } + + if val.Kind() != reflect.Struct { + return nil, fmt.Errorf("query: Values() expects struct input. Got %v", val.Kind()) + } + + err := reflectValue(values, val, "") + return values, err +} + +// reflectValue populates the values parameter from the struct fields in val. +// Embedded structs are followed recursively (using the rules defined in the +// Values function documentation) breadth-first. +func reflectValue(values url.Values, val reflect.Value, scope string) error { + typ := val.Type() + for i := 0; i < typ.NumField(); i++ { + sf := typ.Field(i) + if sf.PkgPath != "" && !sf.Anonymous { + // Skip unexported fields. + continue + } + + sv := val.Field(i) + tag := sf.Tag.Get("url") + if tag == "" || tag == "-" { + continue + } + + name, opts := parseTag(tag) + if name == "" { + name = sf.Name + } + + if scope != "" { + name = scope + "[" + name + "]" + } + + if opts.Contains("omitempty") && isEmptyValue(sv) { + continue + } + + if sv.Type().Implements(queryEncoderType) { + // If sv is a nil pointer and the custom encoder is defined on a non-pointer + // method receiver, set sv to the zero value of the underlying type + if !reflect.Indirect(sv).IsValid() && sv.Type().Elem().Implements(queryEncoderType) { + sv = reflect.New(sv.Type().Elem()) + } + + m := sv.Interface().(QueryEncoder) + if err := m.EncodeQueryValues(name, &values); err != nil { + return err + } + continue + } + + // Recursively dereference pointers, but stop at nil pointers. + for sv.Kind() == reflect.Ptr { + if sv.IsNil() { + break + } + sv = sv.Elem() + } + + if sv.Type() == uuidType || sv.Type() == bytesType || sv.Type() == timeType { + values.Add(name, valueString(sv, opts, sf)) + continue + } + + if sv.Kind() == reflect.Slice || sv.Kind() == reflect.Array { + if sv.Len() == 0 { + // Skip if slice or array is empty. + continue + } + for i := 0; i < sv.Len(); i++ { + value := sv.Index(i) + if isStructPointer(value) && !value.IsNil() { + if err := reflectValue(values, value.Elem(), name); err != nil { + return err + } + } else { + values.Add(name, valueString(value, opts, sf)) + } + } + continue + } + + if sv.Kind() == reflect.Struct { + if err := reflectValue(values, sv, name); err != nil { + return err + } + continue + } + + values.Add(name, valueString(sv, opts, sf)) + } + + return nil +} + +// valueString returns the string representation of a value. +func valueString(v reflect.Value, opts tagOptions, sf reflect.StructField) string { + for v.Kind() == reflect.Ptr { + if v.IsNil() { + return "" + } + v = v.Elem() + } + + if v.Type() == timeType { + t := v.Interface().(time.Time) + if format := sf.Tag.Get("format"); format == "date" { + return t.Format("2006-01-02") + } + return t.Format(time.RFC3339) + } + + if v.Type() == uuidType { + u := v.Interface().(uuid.UUID) + return u.String() + } + + if v.Type() == bytesType { + b := v.Interface().([]byte) + return base64.StdEncoding.EncodeToString(b) + } + + return fmt.Sprint(v.Interface()) +} + +// isEmptyValue checks if a value should be considered empty for the purposes +// of omitting fields with the "omitempty" option. +func isEmptyValue(v reflect.Value) bool { + type zeroable interface { + IsZero() bool + } + + if !v.IsZero() { + if z, ok := v.Interface().(zeroable); ok { + return z.IsZero() + } + } + + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Ptr: + return v.IsNil() + case reflect.Invalid, reflect.Complex64, reflect.Complex128, reflect.Chan, reflect.Func, reflect.Struct, reflect.UnsafePointer: + return false + } + + return false +} + +// isStructPointer returns true if the given reflect.Value is a pointer to a struct. +func isStructPointer(v reflect.Value) bool { + return v.Kind() == reflect.Ptr && v.Elem().Kind() == reflect.Struct +} + +// tagOptions is the string following a comma in a struct field's "url" tag, or +// the empty string. It does not include the leading comma. +type tagOptions []string + +// parseTag splits a struct field's url tag into its name and comma-separated +// options. +func parseTag(tag string) (string, tagOptions) { + s := strings.Split(tag, ",") + return s[0], s[1:] +} + +// Contains checks whether the tagOptions contains the specified option. +func (o tagOptions) Contains(option string) bool { + for _, s := range o { + if s == option { + return true + } + } + return false +} diff --git a/seed/go-sdk/inline-types/internal/query_test.go b/seed/go-sdk/inline-types/internal/query_test.go new file mode 100644 index 00000000000..2e58ccadde1 --- /dev/null +++ b/seed/go-sdk/inline-types/internal/query_test.go @@ -0,0 +1,187 @@ +package internal + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQueryValues(t *testing.T) { + t.Run("empty optional", func(t *testing.T) { + type nested struct { + Value *string `json:"value,omitempty" url:"value,omitempty"` + } + type example struct { + Nested *nested `json:"nested,omitempty" url:"nested,omitempty"` + } + + values, err := QueryValues(&example{}) + require.NoError(t, err) + assert.Empty(t, values) + }) + + t.Run("empty required", func(t *testing.T) { + type nested struct { + Value *string `json:"value,omitempty" url:"value,omitempty"` + } + type example struct { + Required string `json:"required" url:"required"` + Nested *nested `json:"nested,omitempty" url:"nested,omitempty"` + } + + values, err := QueryValues(&example{}) + require.NoError(t, err) + assert.Equal(t, "required=", values.Encode()) + }) + + t.Run("allow multiple", func(t *testing.T) { + type example struct { + Values []string `json:"values" url:"values"` + } + + values, err := QueryValues( + &example{ + Values: []string{"foo", "bar", "baz"}, + }, + ) + require.NoError(t, err) + assert.Equal(t, "values=foo&values=bar&values=baz", values.Encode()) + }) + + t.Run("nested object", func(t *testing.T) { + type nested struct { + Value *string `json:"value,omitempty" url:"value,omitempty"` + } + type example struct { + Required string `json:"required" url:"required"` + Nested *nested `json:"nested,omitempty" url:"nested,omitempty"` + } + + nestedValue := "nestedValue" + values, err := QueryValues( + &example{ + Required: "requiredValue", + Nested: &nested{ + Value: &nestedValue, + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "nested%5Bvalue%5D=nestedValue&required=requiredValue", values.Encode()) + }) + + t.Run("url unspecified", func(t *testing.T) { + type example struct { + Required string `json:"required" url:"required"` + NotFound string `json:"notFound"` + } + + values, err := QueryValues( + &example{ + Required: "requiredValue", + NotFound: "notFound", + }, + ) + require.NoError(t, err) + assert.Equal(t, "required=requiredValue", values.Encode()) + }) + + t.Run("url ignored", func(t *testing.T) { + type example struct { + Required string `json:"required" url:"required"` + NotFound string `json:"notFound" url:"-"` + } + + values, err := QueryValues( + &example{ + Required: "requiredValue", + NotFound: "notFound", + }, + ) + require.NoError(t, err) + assert.Equal(t, "required=requiredValue", values.Encode()) + }) + + t.Run("datetime", func(t *testing.T) { + type example struct { + DateTime time.Time `json:"dateTime" url:"dateTime"` + } + + values, err := QueryValues( + &example{ + DateTime: time.Date(1994, 3, 16, 12, 34, 56, 0, time.UTC), + }, + ) + require.NoError(t, err) + assert.Equal(t, "dateTime=1994-03-16T12%3A34%3A56Z", values.Encode()) + }) + + t.Run("date", func(t *testing.T) { + type example struct { + Date time.Time `json:"date" url:"date" format:"date"` + } + + values, err := QueryValues( + &example{ + Date: time.Date(1994, 3, 16, 12, 34, 56, 0, time.UTC), + }, + ) + require.NoError(t, err) + assert.Equal(t, "date=1994-03-16", values.Encode()) + }) + + t.Run("optional time", func(t *testing.T) { + type example struct { + Date *time.Time `json:"date,omitempty" url:"date,omitempty" format:"date"` + } + + values, err := QueryValues( + &example{}, + ) + require.NoError(t, err) + assert.Empty(t, values.Encode()) + }) + + t.Run("omitempty with non-pointer zero value", func(t *testing.T) { + type enum string + + type example struct { + Enum enum `json:"enum,omitempty" url:"enum,omitempty"` + } + + values, err := QueryValues( + &example{}, + ) + require.NoError(t, err) + assert.Empty(t, values.Encode()) + }) + + t.Run("object array", func(t *testing.T) { + type object struct { + Key string `json:"key" url:"key"` + Value string `json:"value" url:"value"` + } + type example struct { + Objects []*object `json:"objects,omitempty" url:"objects,omitempty"` + } + + values, err := QueryValues( + &example{ + Objects: []*object{ + { + Key: "hello", + Value: "world", + }, + { + Key: "foo", + Value: "bar", + }, + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "objects%5Bkey%5D=hello&objects%5Bkey%5D=foo&objects%5Bvalue%5D=world&objects%5Bvalue%5D=bar", values.Encode()) + }) +} diff --git a/seed/go-sdk/inline-types/internal/retrier.go b/seed/go-sdk/inline-types/internal/retrier.go new file mode 100644 index 00000000000..6040147154b --- /dev/null +++ b/seed/go-sdk/inline-types/internal/retrier.go @@ -0,0 +1,165 @@ +package internal + +import ( + "crypto/rand" + "math/big" + "net/http" + "time" +) + +const ( + defaultRetryAttempts = 2 + minRetryDelay = 500 * time.Millisecond + maxRetryDelay = 5000 * time.Millisecond +) + +// RetryOption adapts the behavior the *Retrier. +type RetryOption func(*retryOptions) + +// RetryFunc is a retriable HTTP function call (i.e. *http.Client.Do). +type RetryFunc func(*http.Request) (*http.Response, error) + +// WithMaxAttempts configures the maximum number of attempts +// of the *Retrier. +func WithMaxAttempts(attempts uint) RetryOption { + return func(opts *retryOptions) { + opts.attempts = attempts + } +} + +// Retrier retries failed requests a configurable number of times with an +// exponential back-off between each retry. +type Retrier struct { + attempts uint +} + +// NewRetrier constructs a new *Retrier with the given options, if any. +func NewRetrier(opts ...RetryOption) *Retrier { + options := new(retryOptions) + for _, opt := range opts { + opt(options) + } + attempts := uint(defaultRetryAttempts) + if options.attempts > 0 { + attempts = options.attempts + } + return &Retrier{ + attempts: attempts, + } +} + +// Run issues the request and, upon failure, retries the request if possible. +// +// The request will be retried as long as the request is deemed retriable and the +// number of retry attempts has not grown larger than the configured retry limit. +func (r *Retrier) Run( + fn RetryFunc, + request *http.Request, + errorDecoder ErrorDecoder, + opts ...RetryOption, +) (*http.Response, error) { + options := new(retryOptions) + for _, opt := range opts { + opt(options) + } + maxRetryAttempts := r.attempts + if options.attempts > 0 { + maxRetryAttempts = options.attempts + } + var ( + retryAttempt uint + previousError error + ) + return r.run( + fn, + request, + errorDecoder, + maxRetryAttempts, + retryAttempt, + previousError, + ) +} + +func (r *Retrier) run( + fn RetryFunc, + request *http.Request, + errorDecoder ErrorDecoder, + maxRetryAttempts uint, + retryAttempt uint, + previousError error, +) (*http.Response, error) { + if retryAttempt >= maxRetryAttempts { + return nil, previousError + } + + // If the call has been cancelled, don't issue the request. + if err := request.Context().Err(); err != nil { + return nil, err + } + + response, err := fn(request) + if err != nil { + return nil, err + } + + if r.shouldRetry(response) { + defer response.Body.Close() + + delay, err := r.retryDelay(retryAttempt) + if err != nil { + return nil, err + } + + time.Sleep(delay) + + return r.run( + fn, + request, + errorDecoder, + maxRetryAttempts, + retryAttempt+1, + decodeError(response, errorDecoder), + ) + } + + return response, nil +} + +// shouldRetry returns true if the request should be retried based on the given +// response status code. +func (r *Retrier) shouldRetry(response *http.Response) bool { + return response.StatusCode == http.StatusTooManyRequests || + response.StatusCode == http.StatusRequestTimeout || + response.StatusCode >= http.StatusInternalServerError +} + +// retryDelay calculates the delay time in milliseconds based on the retry attempt. +func (r *Retrier) retryDelay(retryAttempt uint) (time.Duration, error) { + // Apply exponential backoff. + delay := minRetryDelay + minRetryDelay*time.Duration(retryAttempt*retryAttempt) + + // Do not allow the number to exceed maxRetryDelay. + if delay > maxRetryDelay { + delay = maxRetryDelay + } + + // Apply some itter by randomizing the value in the range of 75%-100%. + max := big.NewInt(int64(delay / 4)) + jitter, err := rand.Int(rand.Reader, max) + if err != nil { + return 0, err + } + + delay -= time.Duration(jitter.Int64()) + + // Never sleep less than the base sleep seconds. + if delay < minRetryDelay { + delay = minRetryDelay + } + + return delay, nil +} + +type retryOptions struct { + attempts uint +} diff --git a/seed/go-sdk/inline-types/internal/retrier_test.go b/seed/go-sdk/inline-types/internal/retrier_test.go new file mode 100644 index 00000000000..750180114ce --- /dev/null +++ b/seed/go-sdk/inline-types/internal/retrier_test.go @@ -0,0 +1,211 @@ +package internal + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/inline-types/fern/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type RetryTestCase struct { + description string + + giveAttempts uint + giveStatusCodes []int + giveResponse *Response + + wantResponse *Response + wantError *core.APIError +} + +func TestRetrier(t *testing.T) { + tests := []*RetryTestCase{ + { + description: "retry request succeeds after multiple failures", + giveAttempts: 3, + giveStatusCodes: []int{ + http.StatusServiceUnavailable, + http.StatusServiceUnavailable, + http.StatusOK, + }, + giveResponse: &Response{ + Id: "1", + }, + wantResponse: &Response{ + Id: "1", + }, + }, + { + description: "retry request fails if MaxAttempts is exceeded", + giveAttempts: 3, + giveStatusCodes: []int{ + http.StatusRequestTimeout, + http.StatusRequestTimeout, + http.StatusRequestTimeout, + http.StatusOK, + }, + wantError: &core.APIError{ + StatusCode: http.StatusRequestTimeout, + }, + }, + { + description: "retry durations increase exponentially and stay within the min and max delay values", + giveAttempts: 4, + giveStatusCodes: []int{ + http.StatusServiceUnavailable, + http.StatusServiceUnavailable, + http.StatusServiceUnavailable, + http.StatusOK, + }, + }, + { + description: "retry does not occur on status code 404", + giveAttempts: 2, + giveStatusCodes: []int{http.StatusNotFound, http.StatusOK}, + wantError: &core.APIError{ + StatusCode: http.StatusNotFound, + }, + }, + { + description: "retries occur on status code 429", + giveAttempts: 2, + giveStatusCodes: []int{http.StatusTooManyRequests, http.StatusOK}, + }, + { + description: "retries occur on status code 408", + giveAttempts: 2, + giveStatusCodes: []int{http.StatusRequestTimeout, http.StatusOK}, + }, + { + description: "retries occur on status code 500", + giveAttempts: 2, + giveStatusCodes: []int{http.StatusInternalServerError, http.StatusOK}, + }, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + var ( + test = tc + server = newTestRetryServer(t, test) + client = server.Client() + ) + + t.Parallel() + + caller := NewCaller( + &CallerParams{ + Client: client, + }, + ) + + var response *Response + err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL, + Method: http.MethodGet, + Request: &Request{}, + Response: &response, + MaxAttempts: test.giveAttempts, + ResponseIsOptional: true, + }, + ) + + if test.wantError != nil { + require.IsType(t, err, &core.APIError{}) + expectedErrorCode := test.wantError.StatusCode + actualErrorCode := err.(*core.APIError).StatusCode + assert.Equal(t, expectedErrorCode, actualErrorCode) + return + } + + require.NoError(t, err) + assert.Equal(t, test.wantResponse, response) + }) + } +} + +// newTestRetryServer returns a new *httptest.Server configured with the +// given test parameters, suitable for testing retries. +func newTestRetryServer(t *testing.T, tc *RetryTestCase) *httptest.Server { + var index int + timestamps := make([]time.Time, 0, len(tc.giveStatusCodes)) + + return httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + timestamps = append(timestamps, time.Now()) + if index > 0 && index < len(expectedRetryDurations) { + // Ensure that the duration between retries increases exponentially, + // and that it is within the minimum and maximum retry delay values. + actualDuration := timestamps[index].Sub(timestamps[index-1]) + expectedDurationMin := expectedRetryDurations[index-1] * 75 / 100 + expectedDurationMax := expectedRetryDurations[index-1] * 125 / 100 + assert.True( + t, + actualDuration >= expectedDurationMin && actualDuration <= expectedDurationMax, + "expected duration to be in range [%v, %v], got %v", + expectedDurationMin, + expectedDurationMax, + actualDuration, + ) + assert.LessOrEqual( + t, + actualDuration, + maxRetryDelay, + "expected duration to be less than the maxRetryDelay (%v), got %v", + maxRetryDelay, + actualDuration, + ) + assert.GreaterOrEqual( + t, + actualDuration, + minRetryDelay, + "expected duration to be greater than the minRetryDelay (%v), got %v", + minRetryDelay, + actualDuration, + ) + } + + request := new(Request) + bytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(bytes, request)) + require.LessOrEqual(t, index, len(tc.giveStatusCodes)) + + statusCode := tc.giveStatusCodes[index] + w.WriteHeader(statusCode) + + if tc.giveResponse != nil && statusCode == http.StatusOK { + bytes, err = json.Marshal(tc.giveResponse) + require.NoError(t, err) + _, err = w.Write(bytes) + require.NoError(t, err) + } + + index++ + }, + ), + ) +} + +// expectedRetryDurations holds an array of calculated retry durations, +// where the index of the array should correspond to the retry attempt. +// +// Values are calculated based off of `minRetryDelay + minRetryDelay*i*i`, with +// a max and min value of 5000ms and 500ms respectively. +var expectedRetryDurations = []time.Duration{ + 500 * time.Millisecond, + 1000 * time.Millisecond, + 2500 * time.Millisecond, + 5000 * time.Millisecond, + 5000 * time.Millisecond, +} diff --git a/seed/go-sdk/inline-types/internal/stringer.go b/seed/go-sdk/inline-types/internal/stringer.go new file mode 100644 index 00000000000..312801851e0 --- /dev/null +++ b/seed/go-sdk/inline-types/internal/stringer.go @@ -0,0 +1,13 @@ +package internal + +import "encoding/json" + +// StringifyJSON returns a pretty JSON string representation of +// the given value. +func StringifyJSON(value interface{}) (string, error) { + bytes, err := json.MarshalIndent(value, "", " ") + if err != nil { + return "", err + } + return string(bytes), nil +} diff --git a/seed/go-sdk/inline-types/internal/time.go b/seed/go-sdk/inline-types/internal/time.go new file mode 100644 index 00000000000..ab0e269fade --- /dev/null +++ b/seed/go-sdk/inline-types/internal/time.go @@ -0,0 +1,137 @@ +package internal + +import ( + "encoding/json" + "time" +) + +const dateFormat = "2006-01-02" + +// DateTime wraps time.Time and adapts its JSON representation +// to conform to a RFC3339 date (e.g. 2006-01-02). +// +// Ref: https://ijmacd.github.io/rfc3339-iso8601 +type Date struct { + t *time.Time +} + +// NewDate returns a new *Date. If the given time.Time +// is nil, nil will be returned. +func NewDate(t time.Time) *Date { + return &Date{t: &t} +} + +// NewOptionalDate returns a new *Date. If the given time.Time +// is nil, nil will be returned. +func NewOptionalDate(t *time.Time) *Date { + if t == nil { + return nil + } + return &Date{t: t} +} + +// Time returns the Date's underlying time, if any. If the +// date is nil, the zero value is returned. +func (d *Date) Time() time.Time { + if d == nil || d.t == nil { + return time.Time{} + } + return *d.t +} + +// TimePtr returns a pointer to the Date's underlying time.Time, if any. +func (d *Date) TimePtr() *time.Time { + if d == nil || d.t == nil { + return nil + } + if d.t.IsZero() { + return nil + } + return d.t +} + +func (d *Date) MarshalJSON() ([]byte, error) { + if d == nil || d.t == nil { + return nil, nil + } + return json.Marshal(d.t.Format(dateFormat)) +} + +func (d *Date) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + parsedTime, err := time.Parse(dateFormat, raw) + if err != nil { + return err + } + + *d = Date{t: &parsedTime} + return nil +} + +// DateTime wraps time.Time and adapts its JSON representation +// to conform to a RFC3339 date-time (e.g. 2017-07-21T17:32:28Z). +// +// Ref: https://ijmacd.github.io/rfc3339-iso8601 +type DateTime struct { + t *time.Time +} + +// NewDateTime returns a new *DateTime. +func NewDateTime(t time.Time) *DateTime { + return &DateTime{t: &t} +} + +// NewOptionalDateTime returns a new *DateTime. If the given time.Time +// is nil, nil will be returned. +func NewOptionalDateTime(t *time.Time) *DateTime { + if t == nil { + return nil + } + return &DateTime{t: t} +} + +// Time returns the DateTime's underlying time, if any. If the +// date-time is nil, the zero value is returned. +func (d *DateTime) Time() time.Time { + if d == nil || d.t == nil { + return time.Time{} + } + return *d.t +} + +// TimePtr returns a pointer to the DateTime's underlying time.Time, if any. +func (d *DateTime) TimePtr() *time.Time { + if d == nil || d.t == nil { + return nil + } + if d.t.IsZero() { + return nil + } + return d.t +} + +func (d *DateTime) MarshalJSON() ([]byte, error) { + if d == nil || d.t == nil { + return nil, nil + } + return json.Marshal(d.t.Format(time.RFC3339)) +} + +func (d *DateTime) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + parsedTime, err := time.Parse(time.RFC3339, raw) + if err != nil { + return err + } + + *d = DateTime{t: &parsedTime} + return nil +} diff --git a/seed/go-sdk/inline-types/option/request_option.go b/seed/go-sdk/inline-types/option/request_option.go new file mode 100644 index 00000000000..b9c9cd68f5e --- /dev/null +++ b/seed/go-sdk/inline-types/option/request_option.go @@ -0,0 +1,64 @@ +// This file was auto-generated by Fern from our API Definition. + +package option + +import ( + core "github.com/inline-types/fern/core" + http "net/http" + url "net/url" +) + +// RequestOption adapts the behavior of an indivdual request. +type RequestOption = core.RequestOption + +// WithBaseURL sets the base URL, overriding the default +// environment, if any. +func WithBaseURL(baseURL string) *core.BaseURLOption { + return &core.BaseURLOption{ + BaseURL: baseURL, + } +} + +// WithHTTPClient uses the given HTTPClient to issue the request. +func WithHTTPClient(httpClient core.HTTPClient) *core.HTTPClientOption { + return &core.HTTPClientOption{ + HTTPClient: httpClient, + } +} + +// WithHTTPHeader adds the given http.Header to the request. +func WithHTTPHeader(httpHeader http.Header) *core.HTTPHeaderOption { + return &core.HTTPHeaderOption{ + // Clone the headers so they can't be modified after the option call. + HTTPHeader: httpHeader.Clone(), + } +} + +// WithBodyProperties adds the given body properties to the request. +func WithBodyProperties(bodyProperties map[string]interface{}) *core.BodyPropertiesOption { + copiedBodyProperties := make(map[string]interface{}, len(bodyProperties)) + for key, value := range bodyProperties { + copiedBodyProperties[key] = value + } + return &core.BodyPropertiesOption{ + BodyProperties: copiedBodyProperties, + } +} + +// WithQueryParameters adds the given query parameters to the request. +func WithQueryParameters(queryParameters url.Values) *core.QueryParametersOption { + copiedQueryParameters := make(url.Values, len(queryParameters)) + for key, values := range queryParameters { + copiedQueryParameters[key] = values + } + return &core.QueryParametersOption{ + QueryParameters: copiedQueryParameters, + } +} + +// WithMaxAttempts configures the maximum number of retry attempts. +func WithMaxAttempts(attempts uint) *core.MaxAttemptsOption { + return &core.MaxAttemptsOption{ + MaxAttempts: attempts, + } +} diff --git a/seed/go-sdk/inline-types/pointer.go b/seed/go-sdk/inline-types/pointer.go new file mode 100644 index 00000000000..d7fa5bf11bb --- /dev/null +++ b/seed/go-sdk/inline-types/pointer.go @@ -0,0 +1,132 @@ +package object + +import ( + "time" + + "github.com/google/uuid" +) + +// Bool returns a pointer to the given bool value. +func Bool(b bool) *bool { + return &b +} + +// Byte returns a pointer to the given byte value. +func Byte(b byte) *byte { + return &b +} + +// Complex64 returns a pointer to the given complex64 value. +func Complex64(c complex64) *complex64 { + return &c +} + +// Complex128 returns a pointer to the given complex128 value. +func Complex128(c complex128) *complex128 { + return &c +} + +// Float32 returns a pointer to the given float32 value. +func Float32(f float32) *float32 { + return &f +} + +// Float64 returns a pointer to the given float64 value. +func Float64(f float64) *float64 { + return &f +} + +// Int returns a pointer to the given int value. +func Int(i int) *int { + return &i +} + +// Int8 returns a pointer to the given int8 value. +func Int8(i int8) *int8 { + return &i +} + +// Int16 returns a pointer to the given int16 value. +func Int16(i int16) *int16 { + return &i +} + +// Int32 returns a pointer to the given int32 value. +func Int32(i int32) *int32 { + return &i +} + +// Int64 returns a pointer to the given int64 value. +func Int64(i int64) *int64 { + return &i +} + +// Rune returns a pointer to the given rune value. +func Rune(r rune) *rune { + return &r +} + +// String returns a pointer to the given string value. +func String(s string) *string { + return &s +} + +// Uint returns a pointer to the given uint value. +func Uint(u uint) *uint { + return &u +} + +// Uint8 returns a pointer to the given uint8 value. +func Uint8(u uint8) *uint8 { + return &u +} + +// Uint16 returns a pointer to the given uint16 value. +func Uint16(u uint16) *uint16 { + return &u +} + +// Uint32 returns a pointer to the given uint32 value. +func Uint32(u uint32) *uint32 { + return &u +} + +// Uint64 returns a pointer to the given uint64 value. +func Uint64(u uint64) *uint64 { + return &u +} + +// Uintptr returns a pointer to the given uintptr value. +func Uintptr(u uintptr) *uintptr { + return &u +} + +// UUID returns a pointer to the given uuid.UUID value. +func UUID(u uuid.UUID) *uuid.UUID { + return &u +} + +// Time returns a pointer to the given time.Time value. +func Time(t time.Time) *time.Time { + return &t +} + +// MustParseDate attempts to parse the given string as a +// date time.Time, and panics upon failure. +func MustParseDate(date string) time.Time { + t, err := time.Parse("2006-01-02", date) + if err != nil { + panic(err) + } + return t +} + +// MustParseDateTime attempts to parse the given string as a +// datetime time.Time, and panics upon failure. +func MustParseDateTime(datetime string) time.Time { + t, err := time.Parse(time.RFC3339, datetime) + if err != nil { + panic(err) + } + return t +} diff --git a/seed/go-sdk/inline-types/snippet-templates.json b/seed/go-sdk/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/go-sdk/inline-types/snippet.json b/seed/go-sdk/inline-types/snippet.json new file mode 100644 index 00000000000..a8b1cd64cf3 --- /dev/null +++ b/seed/go-sdk/inline-types/snippet.json @@ -0,0 +1,15 @@ +{ + "endpoints": [ + { + "id": { + "path": "/root/root", + "method": "POST", + "identifier_override": "endpoint_.getRoot" + }, + "snippet": { + "type": "go", + "client": "import (\n\tcontext \"context\"\n\tfern \"github.com/inline-types/fern\"\n\tfernclient \"github.com/inline-types/fern/client\"\n)\n\nclient := fernclient.NewClient()\nresponse, err := client.GetRoot(\n\tcontext.TODO(),\n\t\u0026fern.PostRootRequest{\n\t\tBar: \u0026fern.InlineType1{\n\t\t\tFoo: \"foo\",\n\t\t\tBar: \u0026fern.NestedInlineType1{\n\t\t\t\tFoo: \"foo\",\n\t\t\t\tBar: \"bar\",\n\t\t\t\tMyEnum: fern.InlineEnumSunny,\n\t\t\t},\n\t\t},\n\t\tFoo: \"foo\",\n\t},\n)\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/go-sdk/inline-types/types.go b/seed/go-sdk/inline-types/types.go new file mode 100644 index 00000000000..e5314b8bcc1 --- /dev/null +++ b/seed/go-sdk/inline-types/types.go @@ -0,0 +1,418 @@ +// This file was auto-generated by Fern from our API Definition. + +package object + +import ( + json "encoding/json" + fmt "fmt" + internal "github.com/inline-types/fern/internal" +) + +type PostRootRequest struct { + Bar *InlineType1 `json:"bar,omitempty" url:"-"` + Foo string `json:"foo" url:"-"` +} + +type InlineEnum string + +const ( + InlineEnumSunny InlineEnum = "SUNNY" + InlineEnumCloudy InlineEnum = "CLOUDY" + InlineEnumRaining InlineEnum = "RAINING" + InlineEnumSnowing InlineEnum = "SNOWING" +) + +func NewInlineEnumFromString(s string) (InlineEnum, error) { + switch s { + case "SUNNY": + return InlineEnumSunny, nil + case "CLOUDY": + return InlineEnumCloudy, nil + case "RAINING": + return InlineEnumRaining, nil + case "SNOWING": + return InlineEnumSnowing, nil + } + var t InlineEnum + return "", fmt.Errorf("%s is not a valid %T", s, t) +} + +func (i InlineEnum) Ptr() *InlineEnum { + return &i +} + +type InlineType1 struct { + Foo string `json:"foo" url:"foo"` + Bar *NestedInlineType1 `json:"bar,omitempty" url:"bar,omitempty"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (i *InlineType1) GetFoo() string { + if i == nil { + return "" + } + return i.Foo +} + +func (i *InlineType1) GetBar() *NestedInlineType1 { + if i == nil { + return nil + } + return i.Bar +} + +func (i *InlineType1) GetExtraProperties() map[string]interface{} { + return i.extraProperties +} + +func (i *InlineType1) UnmarshalJSON(data []byte) error { + type unmarshaler InlineType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *i = InlineType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *i) + if err != nil { + return err + } + i.extraProperties = extraProperties + i.rawJSON = json.RawMessage(data) + return nil +} + +func (i *InlineType1) String() string { + if len(i.rawJSON) > 0 { + if value, err := internal.StringifyJSON(i.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(i); err == nil { + return value + } + return fmt.Sprintf("%#v", i) +} + +type InlineType2 struct { + Baz string `json:"baz" url:"baz"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (i *InlineType2) GetBaz() string { + if i == nil { + return "" + } + return i.Baz +} + +func (i *InlineType2) GetExtraProperties() map[string]interface{} { + return i.extraProperties +} + +func (i *InlineType2) UnmarshalJSON(data []byte) error { + type unmarshaler InlineType2 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *i = InlineType2(value) + extraProperties, err := internal.ExtractExtraProperties(data, *i) + if err != nil { + return err + } + i.extraProperties = extraProperties + i.rawJSON = json.RawMessage(data) + return nil +} + +func (i *InlineType2) String() string { + if len(i.rawJSON) > 0 { + if value, err := internal.StringifyJSON(i.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(i); err == nil { + return value + } + return fmt.Sprintf("%#v", i) +} + +type InlinedDiscriminatedUnion1 struct { + Type string + Type1 *InlineType1 + Type2 *InlineType2 +} + +func NewInlinedDiscriminatedUnion1FromType1(value *InlineType1) *InlinedDiscriminatedUnion1 { + return &InlinedDiscriminatedUnion1{Type: "type1", Type1: value} +} + +func NewInlinedDiscriminatedUnion1FromType2(value *InlineType2) *InlinedDiscriminatedUnion1 { + return &InlinedDiscriminatedUnion1{Type: "type2", Type2: value} +} + +func (i *InlinedDiscriminatedUnion1) GetType() string { + if i == nil { + return "" + } + return i.Type +} + +func (i *InlinedDiscriminatedUnion1) GetType1() *InlineType1 { + if i == nil { + return nil + } + return i.Type1 +} + +func (i *InlinedDiscriminatedUnion1) GetType2() *InlineType2 { + if i == nil { + return nil + } + return i.Type2 +} + +func (i *InlinedDiscriminatedUnion1) UnmarshalJSON(data []byte) error { + var unmarshaler struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &unmarshaler); err != nil { + return err + } + i.Type = unmarshaler.Type + if unmarshaler.Type == "" { + return fmt.Errorf("%T did not include discriminant type", i) + } + switch unmarshaler.Type { + case "type1": + value := new(InlineType1) + if err := json.Unmarshal(data, &value); err != nil { + return err + } + i.Type1 = value + case "type2": + value := new(InlineType2) + if err := json.Unmarshal(data, &value); err != nil { + return err + } + i.Type2 = value + } + return nil +} + +func (i InlinedDiscriminatedUnion1) MarshalJSON() ([]byte, error) { + switch i.Type { + default: + return nil, fmt.Errorf("invalid type %s in %T", i.Type, i) + case "type1": + return internal.MarshalJSONWithExtraProperty(i.Type1, "type", "type1") + case "type2": + return internal.MarshalJSONWithExtraProperty(i.Type2, "type", "type2") + } +} + +type InlinedDiscriminatedUnion1Visitor interface { + VisitType1(*InlineType1) error + VisitType2(*InlineType2) error +} + +func (i *InlinedDiscriminatedUnion1) Accept(visitor InlinedDiscriminatedUnion1Visitor) error { + switch i.Type { + default: + return fmt.Errorf("invalid type %s in %T", i.Type, i) + case "type1": + return visitor.VisitType1(i.Type1) + case "type2": + return visitor.VisitType2(i.Type2) + } +} + +type InlinedUndiscriminatedUnion1 struct { + InlineType1 *InlineType1 + InlineType2 *InlineType2 + + typ string +} + +func NewInlinedUndiscriminatedUnion1FromInlineType1(value *InlineType1) *InlinedUndiscriminatedUnion1 { + return &InlinedUndiscriminatedUnion1{typ: "InlineType1", InlineType1: value} +} + +func NewInlinedUndiscriminatedUnion1FromInlineType2(value *InlineType2) *InlinedUndiscriminatedUnion1 { + return &InlinedUndiscriminatedUnion1{typ: "InlineType2", InlineType2: value} +} + +func (i *InlinedUndiscriminatedUnion1) GetInlineType1() *InlineType1 { + if i == nil { + return nil + } + return i.InlineType1 +} + +func (i *InlinedUndiscriminatedUnion1) GetInlineType2() *InlineType2 { + if i == nil { + return nil + } + return i.InlineType2 +} + +func (i *InlinedUndiscriminatedUnion1) UnmarshalJSON(data []byte) error { + valueInlineType1 := new(InlineType1) + if err := json.Unmarshal(data, &valueInlineType1); err == nil { + i.typ = "InlineType1" + i.InlineType1 = valueInlineType1 + return nil + } + valueInlineType2 := new(InlineType2) + if err := json.Unmarshal(data, &valueInlineType2); err == nil { + i.typ = "InlineType2" + i.InlineType2 = valueInlineType2 + return nil + } + return fmt.Errorf("%s cannot be deserialized as a %T", data, i) +} + +func (i InlinedUndiscriminatedUnion1) MarshalJSON() ([]byte, error) { + if i.typ == "InlineType1" || i.InlineType1 != nil { + return json.Marshal(i.InlineType1) + } + if i.typ == "InlineType2" || i.InlineType2 != nil { + return json.Marshal(i.InlineType2) + } + return nil, fmt.Errorf("type %T does not include a non-empty union type", i) +} + +type InlinedUndiscriminatedUnion1Visitor interface { + VisitInlineType1(*InlineType1) error + VisitInlineType2(*InlineType2) error +} + +func (i *InlinedUndiscriminatedUnion1) Accept(visitor InlinedUndiscriminatedUnion1Visitor) error { + if i.typ == "InlineType1" || i.InlineType1 != nil { + return visitor.VisitInlineType1(i.InlineType1) + } + if i.typ == "InlineType2" || i.InlineType2 != nil { + return visitor.VisitInlineType2(i.InlineType2) + } + return fmt.Errorf("type %T does not include a non-empty union type", i) +} + +type NestedInlineType1 struct { + Foo string `json:"foo" url:"foo"` + Bar string `json:"bar" url:"bar"` + MyEnum InlineEnum `json:"myEnum" url:"myEnum"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (n *NestedInlineType1) GetFoo() string { + if n == nil { + return "" + } + return n.Foo +} + +func (n *NestedInlineType1) GetBar() string { + if n == nil { + return "" + } + return n.Bar +} + +func (n *NestedInlineType1) GetMyEnum() InlineEnum { + if n == nil { + return "" + } + return n.MyEnum +} + +func (n *NestedInlineType1) GetExtraProperties() map[string]interface{} { + return n.extraProperties +} + +func (n *NestedInlineType1) UnmarshalJSON(data []byte) error { + type unmarshaler NestedInlineType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *n = NestedInlineType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *n) + if err != nil { + return err + } + n.extraProperties = extraProperties + n.rawJSON = json.RawMessage(data) + return nil +} + +func (n *NestedInlineType1) String() string { + if len(n.rawJSON) > 0 { + if value, err := internal.StringifyJSON(n.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(n); err == nil { + return value + } + return fmt.Sprintf("%#v", n) +} + +type RootType1 struct { + Foo string `json:"foo" url:"foo"` + Bar *InlineType1 `json:"bar,omitempty" url:"bar,omitempty"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (r *RootType1) GetFoo() string { + if r == nil { + return "" + } + return r.Foo +} + +func (r *RootType1) GetBar() *InlineType1 { + if r == nil { + return nil + } + return r.Bar +} + +func (r *RootType1) GetExtraProperties() map[string]interface{} { + return r.extraProperties +} + +func (r *RootType1) UnmarshalJSON(data []byte) error { + type unmarshaler RootType1 + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *r = RootType1(value) + extraProperties, err := internal.ExtractExtraProperties(data, *r) + if err != nil { + return err + } + r.extraProperties = extraProperties + r.rawJSON = json.RawMessage(data) + return nil +} + +func (r *RootType1) String() string { + if len(r.rawJSON) > 0 { + if value, err := internal.StringifyJSON(r.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(r); err == nil { + return value + } + return fmt.Sprintf("%#v", r) +} diff --git a/seed/java-spring/inline-types/.mock/definition/__package__.yml b/seed/java-spring/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/java-spring/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/java-spring/inline-types/.mock/definition/api.yml b/seed/java-spring/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/java-spring/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/java-spring/inline-types/.mock/fern.config.json b/seed/java-spring/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/java-spring/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/java-spring/inline-types/.mock/generators.yml b/seed/java-spring/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/java-spring/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/java-spring/inline-types/RootService.java b/seed/java-spring/inline-types/RootService.java new file mode 100644 index 00000000000..5375c06c49a --- /dev/null +++ b/seed/java-spring/inline-types/RootService.java @@ -0,0 +1,21 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import requests.PostRootRequest; +import types.RootType1; + +@RequestMapping( + path = "/root" +) +public interface RootService { + @PostMapping( + value = "/root", + produces = "application/json", + consumes = "application/json" + ) + RootType1 getRoot(@RequestBody PostRootRequest body); +} diff --git a/seed/java-spring/inline-types/core/APIException.java b/seed/java-spring/inline-types/core/APIException.java new file mode 100644 index 00000000000..27289cf9b2e --- /dev/null +++ b/seed/java-spring/inline-types/core/APIException.java @@ -0,0 +1,10 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package core; + +import java.lang.Exception; + +public class APIException extends Exception { +} diff --git a/seed/java-spring/inline-types/core/DateTimeDeserializer.java b/seed/java-spring/inline-types/core/DateTimeDeserializer.java new file mode 100644 index 00000000000..3d3174aec00 --- /dev/null +++ b/seed/java-spring/inline-types/core/DateTimeDeserializer.java @@ -0,0 +1,56 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package core; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalQueries; + +/** + * Custom deserializer that handles converting ISO8601 dates into {@link OffsetDateTime} objects. + */ +class DateTimeDeserializer extends JsonDeserializer { + private static final SimpleModule MODULE; + + static { + MODULE = new SimpleModule().addDeserializer(OffsetDateTime.class, new DateTimeDeserializer()); + } + + /** + * Gets a module wrapping this deserializer as an adapter for the Jackson ObjectMapper. + * + * @return A {@link SimpleModule} to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule() { + return MODULE; + } + + @Override + public OffsetDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException { + JsonToken token = parser.currentToken(); + if (token == JsonToken.VALUE_NUMBER_INT) { + return OffsetDateTime.ofInstant(Instant.ofEpochSecond(parser.getValueAsLong()), ZoneOffset.UTC); + } else { + TemporalAccessor temporal = DateTimeFormatter.ISO_DATE_TIME.parseBest( + parser.getValueAsString(), OffsetDateTime::from, LocalDateTime::from); + + if (temporal.query(TemporalQueries.offset()) == null) { + return LocalDateTime.from(temporal).atOffset(ZoneOffset.UTC); + } else { + return OffsetDateTime.from(temporal); + } + } + } +} \ No newline at end of file diff --git a/seed/java-spring/inline-types/core/ObjectMappers.java b/seed/java-spring/inline-types/core/ObjectMappers.java new file mode 100644 index 00000000000..e02822614a8 --- /dev/null +++ b/seed/java-spring/inline-types/core/ObjectMappers.java @@ -0,0 +1,41 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package core; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.IOException; +import java.lang.Integer; +import java.lang.Object; +import java.lang.String; + +public final class ObjectMappers { + public static final ObjectMapper JSON_MAPPER = JsonMapper.builder() + .addModule(new Jdk8Module()) + .addModule(new JavaTimeModule()) + .addModule(DateTimeDeserializer.getModule()) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + + private ObjectMappers() { + } + + public static String stringify(Object o) { + try { + return JSON_MAPPER.setSerializationInclusion(JsonInclude.Include.ALWAYS) + .writerWithDefaultPrettyPrinter() + .writeValueAsString(o); + } + catch (IOException e) { + return o.getClass().getName() + "@" + Integer.toHexString(o.hashCode()); + } + } + } diff --git a/seed/java-spring/inline-types/requests/PostRootRequest.java b/seed/java-spring/inline-types/requests/PostRootRequest.java new file mode 100644 index 00000000000..ab93c023a4e --- /dev/null +++ b/seed/java-spring/inline-types/requests/PostRootRequest.java @@ -0,0 +1,118 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package requests; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import core.ObjectMappers; +import java.lang.Object; +import java.lang.String; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import types.InlineType1; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize( + builder = PostRootRequest.Builder.class +) +public final class PostRootRequest { + private final InlineType1 bar; + + private final String foo; + + private PostRootRequest(InlineType1 bar, String foo) { + this.bar = bar; + this.foo = foo; + } + + @JsonProperty("bar") + public InlineType1 getBar() { + return bar; + } + + @JsonProperty("foo") + public String getFoo() { + return foo; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof PostRootRequest && equalTo((PostRootRequest) other); + } + + private boolean equalTo(PostRootRequest other) { + return bar.equals(other.bar) && foo.equals(other.foo); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.bar, this.foo); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static BarStage builder() { + return new Builder(); + } + + public interface BarStage { + FooStage bar(@NotNull InlineType1 bar); + + Builder from(PostRootRequest other); + } + + public interface FooStage { + _FinalStage foo(@NotNull String foo); + } + + public interface _FinalStage { + PostRootRequest build(); + } + + @JsonIgnoreProperties( + ignoreUnknown = true + ) + public static final class Builder implements BarStage, FooStage, _FinalStage { + private InlineType1 bar; + + private String foo; + + private Builder() { + } + + @java.lang.Override + public Builder from(PostRootRequest other) { + bar(other.getBar()); + foo(other.getFoo()); + return this; + } + + @java.lang.Override + @JsonSetter("bar") + public FooStage bar(@NotNull InlineType1 bar) { + this.bar = Objects.requireNonNull(bar, "bar must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("foo") + public _FinalStage foo(@NotNull String foo) { + this.foo = Objects.requireNonNull(foo, "foo must not be null"); + return this; + } + + @java.lang.Override + public PostRootRequest build() { + return new PostRootRequest(bar, foo); + } + } +} diff --git a/seed/java-spring/inline-types/snippet-templates.json b/seed/java-spring/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/java-spring/inline-types/snippet.json b/seed/java-spring/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/java-spring/inline-types/types/InlineEnum.java b/seed/java-spring/inline-types/types/InlineEnum.java new file mode 100644 index 00000000000..c0251c9bd68 --- /dev/null +++ b/seed/java-spring/inline-types/types/InlineEnum.java @@ -0,0 +1,30 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package types; + +import com.fasterxml.jackson.annotation.JsonValue; +import java.lang.String; + +public enum InlineEnum { + SUNNY("SUNNY"), + + CLOUDY("CLOUDY"), + + RAINING("RAINING"), + + SNOWING("SNOWING"); + + private final String value; + + InlineEnum(String value) { + this.value = value; + } + + @JsonValue + @java.lang.Override + public String toString() { + return this.value; + } +} diff --git a/seed/java-spring/inline-types/types/InlineType1.java b/seed/java-spring/inline-types/types/InlineType1.java new file mode 100644 index 00000000000..f62f87ccbba --- /dev/null +++ b/seed/java-spring/inline-types/types/InlineType1.java @@ -0,0 +1,117 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import core.ObjectMappers; +import java.lang.Object; +import java.lang.String; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize( + builder = InlineType1.Builder.class +) +public final class InlineType1 { + private final String foo; + + private final NestedInlineType1 bar; + + private InlineType1(String foo, NestedInlineType1 bar) { + this.foo = foo; + this.bar = bar; + } + + @JsonProperty("foo") + public String getFoo() { + return foo; + } + + @JsonProperty("bar") + public NestedInlineType1 getBar() { + return bar; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof InlineType1 && equalTo((InlineType1) other); + } + + private boolean equalTo(InlineType1 other) { + return foo.equals(other.foo) && bar.equals(other.bar); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.foo, this.bar); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static FooStage builder() { + return new Builder(); + } + + public interface FooStage { + BarStage foo(@NotNull String foo); + + Builder from(InlineType1 other); + } + + public interface BarStage { + _FinalStage bar(@NotNull NestedInlineType1 bar); + } + + public interface _FinalStage { + InlineType1 build(); + } + + @JsonIgnoreProperties( + ignoreUnknown = true + ) + public static final class Builder implements FooStage, BarStage, _FinalStage { + private String foo; + + private NestedInlineType1 bar; + + private Builder() { + } + + @java.lang.Override + public Builder from(InlineType1 other) { + foo(other.getFoo()); + bar(other.getBar()); + return this; + } + + @java.lang.Override + @JsonSetter("foo") + public BarStage foo(@NotNull String foo) { + this.foo = Objects.requireNonNull(foo, "foo must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("bar") + public _FinalStage bar(@NotNull NestedInlineType1 bar) { + this.bar = Objects.requireNonNull(bar, "bar must not be null"); + return this; + } + + @java.lang.Override + public InlineType1 build() { + return new InlineType1(foo, bar); + } + } +} diff --git a/seed/java-spring/inline-types/types/InlineType2.java b/seed/java-spring/inline-types/types/InlineType2.java new file mode 100644 index 00000000000..1dafe23d4db --- /dev/null +++ b/seed/java-spring/inline-types/types/InlineType2.java @@ -0,0 +1,95 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import core.ObjectMappers; +import java.lang.Object; +import java.lang.String; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize( + builder = InlineType2.Builder.class +) +public final class InlineType2 { + private final String baz; + + private InlineType2(String baz) { + this.baz = baz; + } + + @JsonProperty("baz") + public String getBaz() { + return baz; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof InlineType2 && equalTo((InlineType2) other); + } + + private boolean equalTo(InlineType2 other) { + return baz.equals(other.baz); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.baz); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static BazStage builder() { + return new Builder(); + } + + public interface BazStage { + _FinalStage baz(@NotNull String baz); + + Builder from(InlineType2 other); + } + + public interface _FinalStage { + InlineType2 build(); + } + + @JsonIgnoreProperties( + ignoreUnknown = true + ) + public static final class Builder implements BazStage, _FinalStage { + private String baz; + + private Builder() { + } + + @java.lang.Override + public Builder from(InlineType2 other) { + baz(other.getBaz()); + return this; + } + + @java.lang.Override + @JsonSetter("baz") + public _FinalStage baz(@NotNull String baz) { + this.baz = Objects.requireNonNull(baz, "baz must not be null"); + return this; + } + + @java.lang.Override + public InlineType2 build() { + return new InlineType2(baz); + } + } +} diff --git a/seed/java-spring/inline-types/types/InlinedDiscriminatedUnion1.java b/seed/java-spring/inline-types/types/InlinedDiscriminatedUnion1.java new file mode 100644 index 00000000000..481c78656b0 --- /dev/null +++ b/seed/java-spring/inline-types/types/InlinedDiscriminatedUnion1.java @@ -0,0 +1,224 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.annotation.JsonValue; +import java.lang.Object; +import java.lang.String; +import java.util.Objects; +import java.util.Optional; + +public final class InlinedDiscriminatedUnion1 { + private final Value value; + + @JsonCreator( + mode = JsonCreator.Mode.DELEGATING + ) + private InlinedDiscriminatedUnion1(Value value) { + this.value = value; + } + + public T visit(Visitor visitor) { + return value.visit(visitor); + } + + public static InlinedDiscriminatedUnion1 type1(InlineType1 value) { + return new InlinedDiscriminatedUnion1(new Type1Value(value)); + } + + public static InlinedDiscriminatedUnion1 type2(InlineType2 value) { + return new InlinedDiscriminatedUnion1(new Type2Value(value)); + } + + public boolean isType1() { + return value instanceof Type1Value; + } + + public boolean isType2() { + return value instanceof Type2Value; + } + + public boolean _isUnknown() { + return value instanceof _UnknownValue; + } + + public Optional getType1() { + if (isType1()) { + return Optional.of(((Type1Value) value).value); + } + return Optional.empty(); + } + + public Optional getType2() { + if (isType2()) { + return Optional.of(((Type2Value) value).value); + } + return Optional.empty(); + } + + public Optional _getUnknown() { + if (_isUnknown()) { + return Optional.of(((_UnknownValue) value).value); + } + return Optional.empty(); + } + + @JsonValue + private Value getValue() { + return this.value; + } + + public interface Visitor { + T visitType1(InlineType1 type1); + + T visitType2(InlineType2 type2); + + T _visitUnknown(Object unknownType); + } + + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "type", + visible = true, + defaultImpl = _UnknownValue.class + ) + @JsonSubTypes({ + @JsonSubTypes.Type(Type1Value.class), + @JsonSubTypes.Type(Type2Value.class) + }) + @JsonIgnoreProperties( + ignoreUnknown = true + ) + private interface Value { + T visit(Visitor visitor); + } + + @JsonTypeName("type1") + private static final class Type1Value implements Value { + @JsonUnwrapped + private InlineType1 value; + + @JsonCreator( + mode = JsonCreator.Mode.PROPERTIES + ) + private Type1Value() { + } + + private Type1Value(InlineType1 value) { + this.value = value; + } + + @java.lang.Override + public T visit(Visitor visitor) { + return visitor.visitType1(value); + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof Type1Value && equalTo((Type1Value) other); + } + + private boolean equalTo(Type1Value other) { + return value.equals(other.value); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.value); + } + + @java.lang.Override + public String toString() { + return "InlinedDiscriminatedUnion1{" + "value: " + value + "}"; + } + } + + @JsonTypeName("type2") + private static final class Type2Value implements Value { + @JsonUnwrapped + private InlineType2 value; + + @JsonCreator( + mode = JsonCreator.Mode.PROPERTIES + ) + private Type2Value() { + } + + private Type2Value(InlineType2 value) { + this.value = value; + } + + @java.lang.Override + public T visit(Visitor visitor) { + return visitor.visitType2(value); + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof Type2Value && equalTo((Type2Value) other); + } + + private boolean equalTo(Type2Value other) { + return value.equals(other.value); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.value); + } + + @java.lang.Override + public String toString() { + return "InlinedDiscriminatedUnion1{" + "value: " + value + "}"; + } + } + + private static final class _UnknownValue implements Value { + private String type; + + @JsonValue + private Object value; + + @JsonCreator( + mode = JsonCreator.Mode.PROPERTIES + ) + private _UnknownValue(@JsonProperty("value") Object value) { + } + + @java.lang.Override + public T visit(Visitor visitor) { + return visitor._visitUnknown(value); + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof _UnknownValue && equalTo((_UnknownValue) other); + } + + private boolean equalTo(_UnknownValue other) { + return type.equals(other.type) && value.equals(other.value); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.type, this.value); + } + + @java.lang.Override + public String toString() { + return "InlinedDiscriminatedUnion1{" + "type: " + type + ", value: " + value + "}"; + } + } +} diff --git a/seed/java-spring/inline-types/types/InlinedUndiscriminatedUnion1.java b/seed/java-spring/inline-types/types/InlinedUndiscriminatedUnion1.java new file mode 100644 index 00000000000..2aa7f956849 --- /dev/null +++ b/seed/java-spring/inline-types/types/InlinedUndiscriminatedUnion1.java @@ -0,0 +1,102 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package types; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import core.ObjectMappers; +import java.io.IOException; +import java.lang.IllegalArgumentException; +import java.lang.IllegalStateException; +import java.lang.Object; +import java.lang.String; +import java.util.Objects; + +@JsonDeserialize( + using = InlinedUndiscriminatedUnion1.Deserializer.class +) +public final class InlinedUndiscriminatedUnion1 { + private final Object value; + + private final int type; + + private InlinedUndiscriminatedUnion1(Object value, int type) { + this.value = value; + this.type = type; + } + + @JsonValue + public Object get() { + return this.value; + } + + public T visit(Visitor visitor) { + if(this.type == 0) { + return visitor.visit((InlineType1) this.value); + } else if(this.type == 1) { + return visitor.visit((InlineType2) this.value); + } + throw new IllegalStateException("Failed to visit value. This should never happen."); + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof InlinedUndiscriminatedUnion1 && equalTo((InlinedUndiscriminatedUnion1) other); + } + + private boolean equalTo(InlinedUndiscriminatedUnion1 other) { + return value.equals(other.value); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.value); + } + + @java.lang.Override + public String toString() { + return this.value.toString(); + } + + public static InlinedUndiscriminatedUnion1 of(InlineType1 value) { + return new InlinedUndiscriminatedUnion1(value, 0); + } + + public static InlinedUndiscriminatedUnion1 of(InlineType2 value) { + return new InlinedUndiscriminatedUnion1(value, 1); + } + + public interface Visitor { + T visit(InlineType1 value); + + T visit(InlineType2 value); + } + + static final class Deserializer extends StdDeserializer { + Deserializer() { + super(InlinedUndiscriminatedUnion1.class); + } + + @java.lang.Override + public InlinedUndiscriminatedUnion1 deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + Object value = p.readValueAs(Object.class); + try { + return of(ObjectMappers.JSON_MAPPER.convertValue(value, InlineType1.class)); + } catch(IllegalArgumentException e) { + } + try { + return of(ObjectMappers.JSON_MAPPER.convertValue(value, InlineType2.class)); + } catch(IllegalArgumentException e) { + } + throw new JsonParseException(p, "Failed to deserialize"); + } + } +} diff --git a/seed/java-spring/inline-types/types/NestedInlineType1.java b/seed/java-spring/inline-types/types/NestedInlineType1.java new file mode 100644 index 00000000000..3c9301f4294 --- /dev/null +++ b/seed/java-spring/inline-types/types/NestedInlineType1.java @@ -0,0 +1,139 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import core.ObjectMappers; +import java.lang.Object; +import java.lang.String; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize( + builder = NestedInlineType1.Builder.class +) +public final class NestedInlineType1 { + private final String foo; + + private final String bar; + + private final InlineEnum myEnum; + + private NestedInlineType1(String foo, String bar, InlineEnum myEnum) { + this.foo = foo; + this.bar = bar; + this.myEnum = myEnum; + } + + @JsonProperty("foo") + public String getFoo() { + return foo; + } + + @JsonProperty("bar") + public String getBar() { + return bar; + } + + @JsonProperty("myEnum") + public InlineEnum getMyEnum() { + return myEnum; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof NestedInlineType1 && equalTo((NestedInlineType1) other); + } + + private boolean equalTo(NestedInlineType1 other) { + return foo.equals(other.foo) && bar.equals(other.bar) && myEnum.equals(other.myEnum); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.foo, this.bar, this.myEnum); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static FooStage builder() { + return new Builder(); + } + + public interface FooStage { + BarStage foo(@NotNull String foo); + + Builder from(NestedInlineType1 other); + } + + public interface BarStage { + MyEnumStage bar(@NotNull String bar); + } + + public interface MyEnumStage { + _FinalStage myEnum(@NotNull InlineEnum myEnum); + } + + public interface _FinalStage { + NestedInlineType1 build(); + } + + @JsonIgnoreProperties( + ignoreUnknown = true + ) + public static final class Builder implements FooStage, BarStage, MyEnumStage, _FinalStage { + private String foo; + + private String bar; + + private InlineEnum myEnum; + + private Builder() { + } + + @java.lang.Override + public Builder from(NestedInlineType1 other) { + foo(other.getFoo()); + bar(other.getBar()); + myEnum(other.getMyEnum()); + return this; + } + + @java.lang.Override + @JsonSetter("foo") + public BarStage foo(@NotNull String foo) { + this.foo = Objects.requireNonNull(foo, "foo must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("bar") + public MyEnumStage bar(@NotNull String bar) { + this.bar = Objects.requireNonNull(bar, "bar must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("myEnum") + public _FinalStage myEnum(@NotNull InlineEnum myEnum) { + this.myEnum = Objects.requireNonNull(myEnum, "myEnum must not be null"); + return this; + } + + @java.lang.Override + public NestedInlineType1 build() { + return new NestedInlineType1(foo, bar, myEnum); + } + } +} diff --git a/seed/java-spring/inline-types/types/RootType1.java b/seed/java-spring/inline-types/types/RootType1.java new file mode 100644 index 00000000000..a7512cf5ca8 --- /dev/null +++ b/seed/java-spring/inline-types/types/RootType1.java @@ -0,0 +1,117 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +package types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import core.ObjectMappers; +import java.lang.Object; +import java.lang.String; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize( + builder = RootType1.Builder.class +) +public final class RootType1 { + private final String foo; + + private final InlineType1 bar; + + private RootType1(String foo, InlineType1 bar) { + this.foo = foo; + this.bar = bar; + } + + @JsonProperty("foo") + public String getFoo() { + return foo; + } + + @JsonProperty("bar") + public InlineType1 getBar() { + return bar; + } + + @java.lang.Override + public boolean equals(Object other) { + if (this == other) return true; + return other instanceof RootType1 && equalTo((RootType1) other); + } + + private boolean equalTo(RootType1 other) { + return foo.equals(other.foo) && bar.equals(other.bar); + } + + @java.lang.Override + public int hashCode() { + return Objects.hash(this.foo, this.bar); + } + + @java.lang.Override + public String toString() { + return ObjectMappers.stringify(this); + } + + public static FooStage builder() { + return new Builder(); + } + + public interface FooStage { + BarStage foo(@NotNull String foo); + + Builder from(RootType1 other); + } + + public interface BarStage { + _FinalStage bar(@NotNull InlineType1 bar); + } + + public interface _FinalStage { + RootType1 build(); + } + + @JsonIgnoreProperties( + ignoreUnknown = true + ) + public static final class Builder implements FooStage, BarStage, _FinalStage { + private String foo; + + private InlineType1 bar; + + private Builder() { + } + + @java.lang.Override + public Builder from(RootType1 other) { + foo(other.getFoo()); + bar(other.getBar()); + return this; + } + + @java.lang.Override + @JsonSetter("foo") + public BarStage foo(@NotNull String foo) { + this.foo = Objects.requireNonNull(foo, "foo must not be null"); + return this; + } + + @java.lang.Override + @JsonSetter("bar") + public _FinalStage bar(@NotNull InlineType1 bar) { + this.bar = Objects.requireNonNull(bar, "bar must not be null"); + return this; + } + + @java.lang.Override + public RootType1 build() { + return new RootType1(foo, bar); + } + } +} diff --git a/seed/openapi/inline-types/.mock/definition/__package__.yml b/seed/openapi/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/openapi/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/openapi/inline-types/.mock/definition/api.yml b/seed/openapi/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/openapi/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/openapi/inline-types/.mock/fern.config.json b/seed/openapi/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/openapi/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/openapi/inline-types/.mock/generators.yml b/seed/openapi/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/openapi/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/openapi/inline-types/openapi.yml b/seed/openapi/inline-types/openapi.yml new file mode 100644 index 00000000000..4c6d6dc007c --- /dev/null +++ b/seed/openapi/inline-types/openapi.yml @@ -0,0 +1,117 @@ +openapi: 3.0.1 +info: + title: object + version: '' +paths: + /root/root: + post: + operationId: getRoot + tags: + - '' + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/RootType1' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + bar: + $ref: '#/components/schemas/InlineType1' + foo: + type: string + required: + - bar + - foo +components: + schemas: + RootType1: + title: RootType1 + type: object + properties: + foo: + type: string + bar: + $ref: '#/components/schemas/InlineType1' + required: + - foo + - bar + InlineType1: + title: InlineType1 + type: object + properties: + foo: + type: string + bar: + $ref: '#/components/schemas/NestedInlineType1' + required: + - foo + - bar + InlineType2: + title: InlineType2 + type: object + properties: + baz: + type: string + required: + - baz + NestedInlineType1: + title: NestedInlineType1 + type: object + properties: + foo: + type: string + bar: + type: string + myEnum: + $ref: '#/components/schemas/InlineEnum' + required: + - foo + - bar + - myEnum + InlinedDiscriminatedUnion1: + title: InlinedDiscriminatedUnion1 + oneOf: + - type: object + allOf: + - type: object + properties: + type: + type: string + enum: + - type1 + - $ref: '#/components/schemas/InlineType1' + required: + - type + - type: object + allOf: + - type: object + properties: + type: + type: string + enum: + - type2 + - $ref: '#/components/schemas/InlineType2' + required: + - type + InlinedUndiscriminatedUnion1: + title: InlinedUndiscriminatedUnion1 + oneOf: + - $ref: '#/components/schemas/InlineType1' + - $ref: '#/components/schemas/InlineType2' + InlineEnum: + title: InlineEnum + type: string + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING + securitySchemes: {} diff --git a/seed/openapi/inline-types/snippet-templates.json b/seed/openapi/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/openapi/inline-types/snippet.json b/seed/openapi/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/php-model/inline-types/.github/workflows/ci.yml b/seed/php-model/inline-types/.github/workflows/ci.yml new file mode 100644 index 00000000000..258bf33a19f --- /dev/null +++ b/seed/php-model/inline-types/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + + - name: Install tools + run: | + composer install + + - name: Build + run: | + composer build + + - name: Analyze + run: | + composer analyze + + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + + - name: Install tools + run: | + composer install + + - name: Run Tests + run: | + composer test \ No newline at end of file diff --git a/seed/php-model/inline-types/.gitignore b/seed/php-model/inline-types/.gitignore new file mode 100644 index 00000000000..f38efc46ade --- /dev/null +++ b/seed/php-model/inline-types/.gitignore @@ -0,0 +1,4 @@ +.php-cs-fixer.cache +.phpunit.result.cache +composer.lock +vendor/ \ No newline at end of file diff --git a/seed/php-model/inline-types/.mock/definition/__package__.yml b/seed/php-model/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/php-model/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/php-model/inline-types/.mock/definition/api.yml b/seed/php-model/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/php-model/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/php-model/inline-types/.mock/fern.config.json b/seed/php-model/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/php-model/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/php-model/inline-types/.mock/generators.yml b/seed/php-model/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/php-model/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/php-model/inline-types/composer.json b/seed/php-model/inline-types/composer.json new file mode 100644 index 00000000000..5c96c0056e0 --- /dev/null +++ b/seed/php-model/inline-types/composer.json @@ -0,0 +1,40 @@ + +{ + "name": "seed/seed", + "version": "0.0.1", + "description": "Seed PHP Library", + "keywords": [ + "seed", + "api", + "sdk" + ], + "license": [], + "require": { + "php": "^8.1", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.9" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.12" + }, + "autoload": { + "psr-4": { + "Seed\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "\\Seed\\Tests\\": "tests/" + } + }, + "scripts": { + "build": [ + "@php -l src", + "@php -l tests" + ], + "test": "phpunit", + "analyze": "phpstan analyze src tests" + } +} diff --git a/seed/php-model/inline-types/phpstan.neon b/seed/php-model/inline-types/phpstan.neon new file mode 100644 index 00000000000..29a11a92a19 --- /dev/null +++ b/seed/php-model/inline-types/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: max + paths: + - src + - tests \ No newline at end of file diff --git a/seed/php-model/inline-types/phpunit.xml b/seed/php-model/inline-types/phpunit.xml new file mode 100644 index 00000000000..54630a51163 --- /dev/null +++ b/seed/php-model/inline-types/phpunit.xml @@ -0,0 +1,7 @@ + + + + tests + + + \ No newline at end of file diff --git a/seed/php-model/inline-types/snippet-templates.json b/seed/php-model/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/php-model/inline-types/snippet.json b/seed/php-model/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/php-model/inline-types/src/Core/Json/JsonDecoder.php b/seed/php-model/inline-types/src/Core/Json/JsonDecoder.php new file mode 100644 index 00000000000..2ddff027348 --- /dev/null +++ b/seed/php-model/inline-types/src/Core/Json/JsonDecoder.php @@ -0,0 +1,161 @@ + $type The type definition for deserialization. + * @return mixed[]|array The deserialized array. + * @throws JsonException If the decoded value is not an array. + */ + public static function decodeArray(string $json, array $type): array + { + $decoded = self::decode($json); + if (!is_array($decoded)) { + throw new JsonException("Unexpected non-array json value: " . $json); + } + return JsonDeserializer::deserializeArray($decoded, $type); + } + + /** + * Decodes a JSON string and deserializes it based on the provided union type definition. + * + * @param string $json The JSON string to decode. + * @param Union $union The union type definition for deserialization. + * @return mixed The deserialized value. + * @throws JsonException If the deserialization for all types in the union fails. + */ + public static function decodeUnion(string $json, Union $union): mixed + { + $decoded = self::decode($json); + return JsonDeserializer::deserializeUnion($decoded, $union); + } + /** + * Decodes a JSON string and returns a mixed. + * + * @param string $json The JSON string to decode. + * @return mixed The decoded mixed. + * @throws JsonException If the decoded value is not an mixed. + */ + public static function decodeMixed(string $json): mixed + { + return self::decode($json); + } + + /** + * Decodes a JSON string into a PHP value. + * + * @param string $json The JSON string to decode. + * @return mixed The decoded value. + * @throws JsonException If an error occurs during JSON decoding. + */ + public static function decode(string $json): mixed + { + return json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/seed/php-model/inline-types/src/Core/Json/JsonDeserializer.php b/seed/php-model/inline-types/src/Core/Json/JsonDeserializer.php new file mode 100644 index 00000000000..5f0ca2d7ed0 --- /dev/null +++ b/seed/php-model/inline-types/src/Core/Json/JsonDeserializer.php @@ -0,0 +1,204 @@ + $data The array to be deserialized. + * @param mixed[]|array $type The type definition from the annotation. + * @return mixed[]|array The deserialized array. + * @throws JsonException If deserialization fails. + */ + public static function deserializeArray(array $data, array $type): array + { + return Utils::isMapType($type) + ? self::deserializeMap($data, $type) + : self::deserializeList($data, $type); + } + + /** + * Deserializes a value based on its type definition. + * + * @param mixed $data The data to deserialize. + * @param mixed $type The type definition. + * @return mixed The deserialized value. + * @throws JsonException If deserialization fails. + */ + private static function deserializeValue(mixed $data, mixed $type): mixed + { + if ($type instanceof Union) { + return self::deserializeUnion($data, $type); + } + + if (is_array($type)) { + return self::deserializeArray((array)$data, $type); + } + + if (gettype($type) != "string") { + throw new JsonException("Unexpected non-string type."); + } + + return self::deserializeSingleValue($data, $type); + } + + /** + * Deserializes a value based on the possible types in a union type definition. + * + * @param mixed $data The data to deserialize. + * @param Union $type The union type definition. + * @return mixed The deserialized value. + * @throws JsonException If none of the union types can successfully deserialize the value. + */ + public static function deserializeUnion(mixed $data, Union $type): mixed + { + foreach ($type->types as $unionType) { + try { + return self::deserializeValue($data, $unionType); + } catch (Exception) { + continue; + } + } + $readableType = Utils::getReadableType($data); + throw new JsonException( + "Cannot deserialize value of type $readableType with any of the union types: " . $type + ); + } + + /** + * Deserializes a single value based on its expected type. + * + * @param mixed $data The data to deserialize. + * @param string $type The expected type. + * @return mixed The deserialized value. + * @throws JsonException If deserialization fails. + */ + private static function deserializeSingleValue(mixed $data, string $type): mixed + { + if ($type === 'null' && $data === null) { + return null; + } + + if ($type === 'date' && is_string($data)) { + return self::deserializeDate($data); + } + + if ($type === 'datetime' && is_string($data)) { + return self::deserializeDateTime($data); + } + + if ($type === 'mixed') { + return $data; + } + + if (class_exists($type) && is_array($data)) { + return self::deserializeObject($data, $type); + } + + // Handle floats as a special case since gettype($data) returns "double" for float values in PHP, and because + // floats make come through from json_decoded as integers + if ($type === 'float' && (is_numeric($data))) { + return (float) $data; + } + + if (gettype($data) === $type) { + return $data; + } + + throw new JsonException("Unable to deserialize value of type '" . gettype($data) . "' as '$type'."); + } + + /** + * Deserializes an array into an object of the given type. + * + * @param array $data The data to deserialize. + * @param string $type The class name of the object to deserialize into. + * + * @return object The deserialized object. + * + * @throws JsonException If the type does not implement JsonSerializableType. + */ + public static function deserializeObject(array $data, string $type): object + { + if (!is_subclass_of($type, JsonSerializableType::class)) { + throw new JsonException("$type is not a subclass of JsonSerializableType."); + } + return $type::jsonDeserialize($data); + } + + /** + * Deserializes a map (associative array) with defined key and value types. + * + * @param array $data The associative array to deserialize. + * @param array $type The type definition for the map. + * @return array The deserialized map. + * @throws JsonException If deserialization fails. + */ + private static function deserializeMap(array $data, array $type): array + { + $keyType = array_key_first($type); + $valueType = $type[$keyType]; + $result = []; + + foreach ($data as $key => $item) { + $key = Utils::castKey($key, (string)$keyType); + $result[$key] = self::deserializeValue($item, $valueType); + } + + return $result; + } + + /** + * Deserializes a list (indexed array) with a defined value type. + * + * @param array $data The list to deserialize. + * @param array $type The type definition for the list. + * @return array The deserialized list. + * @throws JsonException If deserialization fails. + */ + private static function deserializeList(array $data, array $type): array + { + $valueType = $type[0]; + return array_map(fn ($item) => self::deserializeValue($item, $valueType), $data); + } +} diff --git a/seed/php-model/inline-types/src/Core/Json/JsonEncoder.php b/seed/php-model/inline-types/src/Core/Json/JsonEncoder.php new file mode 100644 index 00000000000..0dbf3fcc994 --- /dev/null +++ b/seed/php-model/inline-types/src/Core/Json/JsonEncoder.php @@ -0,0 +1,20 @@ +jsonSerialize(); + $encoded = JsonEncoder::encode($serializedObject); + if (!$encoded) { + throw new Exception("Could not encode type"); + } + return $encoded; + } + + /** + * Serializes the object to an array. + * + * @return mixed[] Array representation of the object. + * @throws JsonException If serialization fails. + */ + public function jsonSerialize(): array + { + $result = []; + $reflectionClass = new \ReflectionClass($this); + + foreach ($reflectionClass->getProperties() as $property) { + $jsonKey = self::getJsonKey($property); + if ($jsonKey == null) { + continue; + } + $value = $property->getValue($this); + + // Handle DateTime properties + $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; + if ($dateTypeAttr && $value instanceof DateTime) { + $dateType = $dateTypeAttr->newInstance()->type; + $value = ($dateType === Date::TYPE_DATE) + ? JsonSerializer::serializeDate($value) + : JsonSerializer::serializeDateTime($value); + } + + // Handle Union annotations + $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; + if ($unionTypeAttr) { + $unionType = $unionTypeAttr->newInstance(); + $value = JsonSerializer::serializeUnion($value, $unionType); + } + + // Handle arrays with type annotations + $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; + if ($arrayTypeAttr && is_array($value)) { + $arrayType = $arrayTypeAttr->newInstance()->type; + $value = JsonSerializer::serializeArray($value, $arrayType); + } + + // Handle object + if (is_object($value)) { + $value = JsonSerializer::serializeObject($value); + } + + if ($value !== null) { + $result[$jsonKey] = $value; + } + } + + return $result; + } + + /** + * Deserializes a JSON string into an instance of the calling class. + * + * @param string $json JSON string to deserialize. + * @return static Deserialized object. + * @throws JsonException If decoding fails or the result is not an array. + * @throws Exception If deserialization fails. + */ + public static function fromJson(string $json): static + { + $decodedJson = JsonDecoder::decode($json); + if (!is_array($decodedJson)) { + throw new JsonException("Unexpected non-array decoded type: " . gettype($decodedJson)); + } + return self::jsonDeserialize($decodedJson); + } + + /** + * Deserializes an array into an instance of the calling class. + * + * @param array $data Array data to deserialize. + * @return static Deserialized object. + * @throws JsonException If deserialization fails. + */ + public static function jsonDeserialize(array $data): static + { + $reflectionClass = new \ReflectionClass(static::class); + $constructor = $reflectionClass->getConstructor(); + + if ($constructor === null) { + throw new JsonException("No constructor found."); + } + + $args = []; + foreach ($reflectionClass->getProperties() as $property) { + $jsonKey = self::getJsonKey($property) ?? $property->getName(); + + if (array_key_exists($jsonKey, $data)) { + $value = $data[$jsonKey]; + + // Handle Date annotation + $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; + if ($dateTypeAttr) { + $dateType = $dateTypeAttr->newInstance()->type; + if (!is_string($value)) { + throw new JsonException("Unexpected non-string type for date."); + } + $value = ($dateType === Date::TYPE_DATE) + ? JsonDeserializer::deserializeDate($value) + : JsonDeserializer::deserializeDateTime($value); + } + + // Handle Array annotation + $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; + if (is_array($value) && $arrayTypeAttr) { + $arrayType = $arrayTypeAttr->newInstance()->type; + $value = JsonDeserializer::deserializeArray($value, $arrayType); + } + + // Handle Union annotations + $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; + if ($unionTypeAttr) { + $unionType = $unionTypeAttr->newInstance(); + $value = JsonDeserializer::deserializeUnion($value, $unionType); + } + + // Handle object + $type = $property->getType(); + if (is_array($value) && $type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $value = JsonDeserializer::deserializeObject($value, $type->getName()); + } + + $args[$property->getName()] = $value; + } else { + $defaultValue = $property->getDefaultValue() ?? null; + $args[$property->getName()] = $defaultValue; + } + } + // @phpstan-ignore-next-line + return new static($args); + } + + /** + * Retrieves the JSON key associated with a property. + * + * @param ReflectionProperty $property The reflection property. + * @return ?string The JSON key, or null if not available. + */ + private static function getJsonKey(ReflectionProperty $property): ?string + { + $jsonPropertyAttr = $property->getAttributes(JsonProperty::class)[0] ?? null; + return $jsonPropertyAttr?->newInstance()?->name; + } +} diff --git a/seed/php-model/inline-types/src/Core/Json/JsonSerializer.php b/seed/php-model/inline-types/src/Core/Json/JsonSerializer.php new file mode 100644 index 00000000000..7dd6fe517af --- /dev/null +++ b/seed/php-model/inline-types/src/Core/Json/JsonSerializer.php @@ -0,0 +1,192 @@ +format(Constant::DateFormat); + } + + /** + * Serializes a DateTime object into a string using the date-time format. + * + * @param DateTime $date The DateTime object to serialize. + * @return string The serialized date-time string. + */ + public static function serializeDateTime(DateTime $date): string + { + return $date->format(Constant::DateTimeFormat); + } + + /** + * Serializes an array based on type annotations (either a list or map). + * + * @param mixed[]|array $data The array to be serialized. + * @param mixed[]|array $type The type definition from the annotation. + * @return mixed[]|array The serialized array. + * @throws JsonException If serialization fails. + */ + public static function serializeArray(array $data, array $type): array + { + return Utils::isMapType($type) + ? self::serializeMap($data, $type) + : self::serializeList($data, $type); + } + + /** + * Serializes a value based on its type definition. + * + * @param mixed $data The value to serialize. + * @param mixed $type The type definition. + * @return mixed The serialized value. + * @throws JsonException If serialization fails. + */ + private static function serializeValue(mixed $data, mixed $type): mixed + { + if ($type instanceof Union) { + return self::serializeUnion($data, $type); + } + + if (is_array($type)) { + return self::serializeArray((array)$data, $type); + } + + if (gettype($type) != "string") { + throw new JsonException("Unexpected non-string type."); + } + + return self::serializeSingleValue($data, $type); + } + + /** + * Serializes a value for a union type definition. + * + * @param mixed $data The value to serialize. + * @param Union $unionType The union type definition. + * @return mixed The serialized value. + * @throws JsonException If serialization fails for all union types. + */ + public static function serializeUnion(mixed $data, Union $unionType): mixed + { + foreach ($unionType->types as $type) { + try { + return self::serializeValue($data, $type); + } catch (Exception) { + // Try the next type in the union + continue; + } + } + $readableType = Utils::getReadableType($data); + throw new JsonException( + "Cannot serialize value of type $readableType with any of the union types: " . $unionType + ); + } + + /** + * Serializes a single value based on its type. + * + * @param mixed $data The value to serialize. + * @param string $type The expected type. + * @return mixed The serialized value. + * @throws JsonException If serialization fails. + */ + private static function serializeSingleValue(mixed $data, string $type): mixed + { + if ($type === 'null' && $data === null) { + return null; + } + + if (($type === 'date' || $type === 'datetime') && $data instanceof DateTime) { + return $type === 'date' ? self::serializeDate($data) : self::serializeDateTime($data); + } + + if ($type === 'mixed') { + return $data; + } + + if (class_exists($type) && $data instanceof $type) { + return self::serializeObject($data); + } + + // Handle floats as a special case since gettype($data) returns "double" for float values in PHP. + if ($type === 'float' && is_float($data)) { + return $data; + } + + if (gettype($data) === $type) { + return $data; + } + + throw new JsonException("Unable to serialize value of type '" . gettype($data) . "' as '$type'."); + } + + /** + * Serializes an object to a JSON-serializable format. + * + * @param object $data The object to serialize. + * @return mixed The serialized data. + * @throws JsonException If the object does not implement JsonSerializable. + */ + public static function serializeObject(object $data): mixed + { + if (!is_subclass_of($data, JsonSerializable::class)) { + $type = get_class($data); + throw new JsonException("Class $type must implement JsonSerializable."); + } + return $data->jsonSerialize(); + } + + /** + * Serializes a map (associative array) with defined key and value types. + * + * @param array $data The associative array to serialize. + * @param array $type The type definition for the map. + * @return array The serialized map. + * @throws JsonException If serialization fails. + */ + private static function serializeMap(array $data, array $type): array + { + $keyType = array_key_first($type); + if ($keyType === null) { + throw new JsonException("Unexpected no key in ArrayType."); + } + $valueType = $type[$keyType]; + $result = []; + + foreach ($data as $key => $item) { + $key = Utils::castKey($key, $keyType); + $result[$key] = self::serializeValue($item, $valueType); + } + + return $result; + } + + /** + * Serializes a list (indexed array) where only the value type is defined. + * + * @param array $data The list to serialize. + * @param array $type The type definition for the list. + * @return array The serialized list. + * @throws JsonException If serialization fails. + */ + private static function serializeList(array $data, array $type): array + { + $valueType = $type[0]; + return array_map(fn ($item) => self::serializeValue($item, $valueType), $data); + } +} diff --git a/seed/php-model/inline-types/src/Core/Json/Utils.php b/seed/php-model/inline-types/src/Core/Json/Utils.php new file mode 100644 index 00000000000..7577c058916 --- /dev/null +++ b/seed/php-model/inline-types/src/Core/Json/Utils.php @@ -0,0 +1,61 @@ + $type The type definition from the annotation. + * @return bool True if the type is a map, false if it's a list. + */ + public static function isMapType(array $type): bool + { + return count($type) === 1 && !array_is_list($type); + } + + /** + * Casts the key to the appropriate type based on the key type. + * + * @param mixed $key The key to be cast. + * @param string $keyType The type to cast the key to ('string', 'integer', 'float'). + * @return mixed The casted key. + * @throws JsonException + */ + public static function castKey(mixed $key, string $keyType): mixed + { + if (!is_scalar($key)) { + throw new JsonException("Key must be a scalar type."); + } + return match ($keyType) { + 'integer' => (int)$key, + 'float' => (float)$key, + 'string' => (string)$key, + default => $key, + }; + } + + /** + * Returns a human-readable representation of the input's type. + * + * @param mixed $input The input value to determine the type of. + * @return string A readable description of the input type. + */ + public static function getReadableType(mixed $input): string + { + if (is_object($input)) { + return get_class($input); + } elseif (is_array($input)) { + return 'array(' . count($input) . ' items)'; + } elseif (is_null($input)) { + return 'null'; + } else { + return gettype($input); + } + } +} diff --git a/seed/php-model/inline-types/src/Core/Types/ArrayType.php b/seed/php-model/inline-types/src/Core/Types/ArrayType.php new file mode 100644 index 00000000000..a26d29008ec --- /dev/null +++ b/seed/php-model/inline-types/src/Core/Types/ArrayType.php @@ -0,0 +1,16 @@ + 'valueType'] for maps, or ['valueType'] for lists + */ + public function __construct(public array $type) + { + } +} diff --git a/seed/php-model/inline-types/src/Core/Types/Constant.php b/seed/php-model/inline-types/src/Core/Types/Constant.php new file mode 100644 index 00000000000..5ac4518cc6d --- /dev/null +++ b/seed/php-model/inline-types/src/Core/Types/Constant.php @@ -0,0 +1,12 @@ +> The types allowed for this property, which can be strings, arrays, or nested Union types. + */ + public array $types; + + /** + * Constructor for the Union attribute. + * + * @param string|Union|array ...$types The list of types that the property can accept. + * This can include primitive types (e.g., 'string', 'int'), arrays, or other Union instances. + * + * Example: + * ```php + * #[Union('string', 'null', 'date', new Union('boolean', 'int'))] + * ``` + */ + public function __construct(string|Union|array ...$types) + { + $this->types = $types; + } + + /** + * Converts the Union type to a string representation. + * + * @return string A string representation of the union types. + */ + public function __toString(): string + { + return implode(' | ', array_map(function ($type) { + if (is_string($type)) { + return $type; + } elseif ($type instanceof Union) { + return (string) $type; // Recursively handle nested unions + } elseif (is_array($type)) { + return 'array'; // Handle arrays + } + }, $this->types)); + } +} diff --git a/seed/php-model/inline-types/src/InlineEnum.php b/seed/php-model/inline-types/src/InlineEnum.php new file mode 100644 index 00000000000..5df508c6949 --- /dev/null +++ b/seed/php-model/inline-types/src/InlineEnum.php @@ -0,0 +1,11 @@ +foo = $values['foo']; + $this->bar = $values['bar']; + } +} diff --git a/seed/php-model/inline-types/src/InlineType2.php b/seed/php-model/inline-types/src/InlineType2.php new file mode 100644 index 00000000000..f59b4e9e761 --- /dev/null +++ b/seed/php-model/inline-types/src/InlineType2.php @@ -0,0 +1,26 @@ +baz = $values['baz']; + } +} diff --git a/seed/php-model/inline-types/src/NestedInlineType1.php b/seed/php-model/inline-types/src/NestedInlineType1.php new file mode 100644 index 00000000000..4bb55ace4a5 --- /dev/null +++ b/seed/php-model/inline-types/src/NestedInlineType1.php @@ -0,0 +1,42 @@ + $myEnum + */ + #[JsonProperty('myEnum')] + public string $myEnum; + + /** + * @param array{ + * foo: string, + * bar: string, + * myEnum: value-of, + * } $values + */ + public function __construct( + array $values, + ) { + $this->foo = $values['foo']; + $this->bar = $values['bar']; + $this->myEnum = $values['myEnum']; + } +} diff --git a/seed/php-model/inline-types/src/RootType1.php b/seed/php-model/inline-types/src/RootType1.php new file mode 100644 index 00000000000..e0a6ecf3e71 --- /dev/null +++ b/seed/php-model/inline-types/src/RootType1.php @@ -0,0 +1,34 @@ +foo = $values['foo']; + $this->bar = $values['bar']; + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/DateArrayTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/DateArrayTest.php new file mode 100644 index 00000000000..a72cfdbdd22 --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/DateArrayTest.php @@ -0,0 +1,54 @@ +dates = $values['dates']; + } +} + +class DateArrayTest extends TestCase +{ + public function testDateTimeInArrays(): void + { + $expectedJson = json_encode( + [ + 'dates' => ['2023-01-01', '2023-02-01', '2023-03-01'] + ], + JSON_THROW_ON_ERROR + ); + + $object = DateArray::fromJson($expectedJson); + $this->assertInstanceOf(DateTime::class, $object->dates[0], 'dates[0] should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->dates[0]->format('Y-m-d'), 'dates[0] should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->dates[1], 'dates[1] should be a DateTime instance.'); + $this->assertEquals('2023-02-01', $object->dates[1]->format('Y-m-d'), 'dates[1] should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->dates[2], 'dates[2] should be a DateTime instance.'); + $this->assertEquals('2023-03-01', $object->dates[2]->format('Y-m-d'), 'dates[2] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for dates array.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/EmptyArrayTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/EmptyArrayTest.php new file mode 100644 index 00000000000..d243a08916d --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/EmptyArrayTest.php @@ -0,0 +1,71 @@ + $emptyMapArray + */ + #[JsonProperty('empty_map_array')] + #[ArrayType(['integer' => new Union('string', 'null')])] + public array $emptyMapArray; + + /** + * @var array $emptyDatesArray + */ + #[ArrayType([new Union('date', 'null')])] + #[JsonProperty('empty_dates_array')] + public array $emptyDatesArray; + + /** + * @param array{ + * emptyStringArray: string[], + * emptyMapArray: array, + * emptyDatesArray: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->emptyStringArray = $values['emptyStringArray']; + $this->emptyMapArray = $values['emptyMapArray']; + $this->emptyDatesArray = $values['emptyDatesArray']; + } +} + +class EmptyArrayTest extends TestCase +{ + public function testEmptyArray(): void + { + $expectedJson = json_encode( + [ + 'empty_string_array' => [], + 'empty_map_array' => [], + 'empty_dates_array' => [] + ], + JSON_THROW_ON_ERROR + ); + + $object = EmptyArray::fromJson($expectedJson); + $this->assertEmpty($object->emptyStringArray, 'empty_string_array should be empty.'); + $this->assertEmpty($object->emptyMapArray, 'empty_map_array should be empty.'); + $this->assertEmpty($object->emptyDatesArray, 'empty_dates_array should be empty.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for EmptyArraysType.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/EnumTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/EnumTest.php new file mode 100644 index 00000000000..bf83d5b8ab0 --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/EnumTest.php @@ -0,0 +1,76 @@ +value; + } +} + +class ShapeType extends JsonSerializableType +{ + /** + * @var Shape $shape + */ + #[JsonProperty('shape')] + public Shape $shape; + + /** + * @var Shape[] $shapes + */ + #[ArrayType([Shape::class])] + #[JsonProperty('shapes')] + public array $shapes; + + /** + * @param Shape $shape + * @param Shape[] $shapes + */ + public function __construct( + Shape $shape, + array $shapes, + ) { + $this->shape = $shape; + $this->shapes = $shapes; + } +} + +class EnumTest extends TestCase +{ + public function testEnumSerialization(): void + { + $object = new ShapeType( + Shape::Circle, + [Shape::Square, Shape::Circle, Shape::Triangle] + ); + + $expectedJson = json_encode([ + 'shape' => 'CIRCLE', + 'shapes' => ['SQUARE', 'CIRCLE', 'TRIANGLE'] + ], JSON_THROW_ON_ERROR); + + $actualJson = $object->toJson(); + + $this->assertJsonStringEqualsJsonString( + $expectedJson, + $actualJson, + 'Serialized JSON does not match expected JSON for shape and shapes properties.' + ); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/ExhaustiveTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/ExhaustiveTest.php new file mode 100644 index 00000000000..f542d6a535d --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/ExhaustiveTest.php @@ -0,0 +1,197 @@ +nestedProperty = $values['nestedProperty']; + } +} + +class Type extends JsonSerializableType +{ + /** + * @var Nested nestedType + */ + #[JsonProperty('nested_type')] + public Nested $nestedType; /** + + * @var string $simpleProperty + */ + #[JsonProperty('simple_property')] + public string $simpleProperty; + + /** + * @var DateTime $dateProperty + */ + #[Date(Date::TYPE_DATE)] + #[JsonProperty('date_property')] + public DateTime $dateProperty; + + /** + * @var DateTime $datetimeProperty + */ + #[Date(Date::TYPE_DATETIME)] + #[JsonProperty('datetime_property')] + public DateTime $datetimeProperty; + + /** + * @var array $stringArray + */ + #[ArrayType(['string'])] + #[JsonProperty('string_array')] + public array $stringArray; + + /** + * @var array $mapProperty + */ + #[ArrayType(['string' => 'integer'])] + #[JsonProperty('map_property')] + public array $mapProperty; + + /** + * @var array $objectArray + */ + #[ArrayType(['integer' => new Union(Nested::class, 'null')])] + #[JsonProperty('object_array')] + public array $objectArray; + + /** + * @var array> $nestedArray + */ + #[ArrayType(['integer' => ['integer' => new Union('string', 'null')]])] + #[JsonProperty('nested_array')] + public array $nestedArray; + + /** + * @var array $datesArray + */ + #[ArrayType([new Union('date', 'null')])] + #[JsonProperty('dates_array')] + public array $datesArray; + + /** + * @var string|null $nullableProperty + */ + #[JsonProperty('nullable_property')] + public ?string $nullableProperty; + + /** + * @param array{ + * nestedType: Nested, + * simpleProperty: string, + * dateProperty: DateTime, + * datetimeProperty: DateTime, + * stringArray: array, + * mapProperty: array, + * objectArray: array, + * nestedArray: array>, + * datesArray: array, + * nullableProperty?: string|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nestedType = $values['nestedType']; + $this->simpleProperty = $values['simpleProperty']; + $this->dateProperty = $values['dateProperty']; + $this->datetimeProperty = $values['datetimeProperty']; + $this->stringArray = $values['stringArray']; + $this->mapProperty = $values['mapProperty']; + $this->objectArray = $values['objectArray']; + $this->nestedArray = $values['nestedArray']; + $this->datesArray = $values['datesArray']; + $this->nullableProperty = $values['nullableProperty'] ?? null; + } +} + +class ExhaustiveTest extends TestCase +{ + /** + * Test serialization and deserialization of all types in Type. + */ + public function testExhaustive(): void + { + $expectedJson = json_encode( + [ + 'nested_type' => ['nested_property' => '1995-07-20'], + 'simple_property' => 'Test String', + // Omit 'nullable_property' to test null serialization + 'date_property' => '2023-01-01', + 'datetime_property' => '2023-01-01T12:34:56+00:00', + 'string_array' => ['one', 'two', 'three'], + 'map_property' => ['key1' => 1, 'key2' => 2], + 'object_array' => [ + 1 => ['nested_property' => '2021-07-20'], + 2 => null, // Testing nullable objects in array + ], + 'nested_array' => [ + 1 => [1 => 'value1', 2 => null], // Testing nullable strings in nested array + 2 => [3 => 'value3', 4 => 'value4'] + ], + 'dates_array' => ['2023-01-01', null, '2023-03-01'] // Testing nullable dates in array> + ], + JSON_THROW_ON_ERROR + ); + + $object = Type::fromJson($expectedJson); + + // Check that nullable property is null and not included in JSON + $this->assertNull($object->nullableProperty, 'Nullable property should be null.'); + + // Check date properties + $this->assertInstanceOf(DateTime::class, $object->dateProperty, 'date_property should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->dateProperty->format('Y-m-d'), 'date_property should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->datetimeProperty, 'datetime_property should be a DateTime instance.'); + $this->assertEquals('2023-01-01 12:34:56', $object->datetimeProperty->format('Y-m-d H:i:s'), 'datetime_property should have the correct datetime.'); + + // Check scalar arrays + $this->assertEquals(['one', 'two', 'three'], $object->stringArray, 'string_array should match the original data.'); + $this->assertEquals(['key1' => 1, 'key2' => 2], $object->mapProperty, 'map_property should match the original data.'); + + // Check object array with nullable elements + $this->assertInstanceOf(Nested::class, $object->objectArray[1], 'object_array[1] should be an instance of TestNestedType1.'); + $this->assertEquals('2021-07-20', $object->objectArray[1]->nestedProperty->format('Y-m-d'), 'object_array[1]->nestedProperty should match the original data.'); + $this->assertNull($object->objectArray[2], 'object_array[2] should be null.'); + + // Check nested array with nullable strings + $this->assertEquals('value1', $object->nestedArray[1][1], 'nested_array[1][1] should match the original data.'); + $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); + $this->assertEquals('value3', $object->nestedArray[2][3], 'nested_array[2][3] should match the original data.'); + $this->assertEquals('value4', $object->nestedArray[2][4], 'nested_array[2][4] should match the original data.'); + + // Check dates array with nullable DateTime objects + $this->assertInstanceOf(DateTime::class, $object->datesArray[0], 'dates_array[0] should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->datesArray[0]->format('Y-m-d'), 'dates_array[0] should have the correct date.'); + $this->assertNull($object->datesArray[1], 'dates_array[1] should be null.'); + $this->assertInstanceOf(DateTime::class, $object->datesArray[2], 'dates_array[2] should be a DateTime instance.'); + $this->assertEquals('2023-03-01', $object->datesArray[2]->format('Y-m-d'), 'dates_array[2] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'The serialized JSON does not match the original JSON.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/InvalidTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/InvalidTest.php new file mode 100644 index 00000000000..7d1d79406a5 --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/InvalidTest.php @@ -0,0 +1,42 @@ +integerProperty = $values['integerProperty']; + } +} + +class InvalidTest extends TestCase +{ + public function testInvalidJsonThrowsException(): void + { + $this->expectException(\TypeError::class); + $json = json_encode( + [ + 'integer_property' => 'not_an_integer' + ], + JSON_THROW_ON_ERROR + ); + Invalid::fromJson($json); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/NestedUnionArrayTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/NestedUnionArrayTest.php new file mode 100644 index 00000000000..0fcdd06667e --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/NestedUnionArrayTest.php @@ -0,0 +1,89 @@ +nestedProperty = $values['nestedProperty']; + } +} + +class NestedUnionArray extends JsonSerializableType +{ + /** + * @var array> $nestedArray + */ + #[ArrayType(['integer' => ['integer' => new Union(UnionObject::class, 'null', 'date')]])] + #[JsonProperty('nested_array')] + public array $nestedArray; + + /** + * @param array{ + * nestedArray: array>, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nestedArray = $values['nestedArray']; + } +} + +class NestedUnionArrayTest extends TestCase +{ + public function testNestedUnionArray(): void + { + $expectedJson = json_encode( + [ + 'nested_array' => [ + 1 => [ + 1 => ['nested_property' => 'Nested One'], + 2 => null, + 4 => '2023-01-02' + ], + 2 => [ + 5 => ['nested_property' => 'Nested Two'], + 7 => '2023-02-02' + ] + ] + ], + JSON_THROW_ON_ERROR + ); + + $object = NestedUnionArray::fromJson($expectedJson); + $this->assertInstanceOf(UnionObject::class, $object->nestedArray[1][1], 'nested_array[1][1] should be an instance of Object.'); + $this->assertEquals('Nested One', $object->nestedArray[1][1]->nestedProperty, 'nested_array[1][1]->nestedProperty should match the original data.'); + $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); + $this->assertInstanceOf(DateTime::class, $object->nestedArray[1][4], 'nested_array[1][4] should be a DateTime instance.'); + $this->assertEquals('2023-01-02T00:00:00+00:00', $object->nestedArray[1][4]->format(Constant::DateTimeFormat), 'nested_array[1][4] should have the correct datetime.'); + $this->assertInstanceOf(UnionObject::class, $object->nestedArray[2][5], 'nested_array[2][5] should be an instance of Object.'); + $this->assertEquals('Nested Two', $object->nestedArray[2][5]->nestedProperty, 'nested_array[2][5]->nestedProperty should match the original data.'); + $this->assertInstanceOf(DateTime::class, $object->nestedArray[2][7], 'nested_array[1][4] should be a DateTime instance.'); + $this->assertEquals('2023-02-02', $object->nestedArray[2][7]->format('Y-m-d'), 'nested_array[1][4] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nested_array.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/NullPropertyTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/NullPropertyTest.php new file mode 100644 index 00000000000..ce20a244282 --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/NullPropertyTest.php @@ -0,0 +1,53 @@ +nonNullProperty = $values['nonNullProperty']; + $this->nullProperty = $values['nullProperty'] ?? null; + } +} + +class NullPropertyTest extends TestCase +{ + public function testNullPropertiesAreOmitted(): void + { + $object = new NullProperty( + [ + "nonNullProperty" => "Test String", + "nullProperty" => null + ] + ); + + $serialized = $object->jsonSerialize(); + $this->assertArrayHasKey('non_null_property', $serialized, 'non_null_property should be present in the serialized JSON.'); + $this->assertArrayNotHasKey('null_property', $serialized, 'null_property should be omitted from the serialized JSON.'); + $this->assertEquals('Test String', $serialized['non_null_property'], 'non_null_property should have the correct value.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/NullableArrayTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/NullableArrayTest.php new file mode 100644 index 00000000000..fe0f19de6b1 --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/NullableArrayTest.php @@ -0,0 +1,49 @@ + $nullableStringArray + */ + #[ArrayType([new Union('string', 'null')])] + #[JsonProperty('nullable_string_array')] + public array $nullableStringArray; + + /** + * @param array{ + * nullableStringArray: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nullableStringArray = $values['nullableStringArray']; + } +} + +class NullableArrayTest extends TestCase +{ + public function testNullableArray(): void + { + $expectedJson = json_encode( + [ + 'nullable_string_array' => ['one', null, 'three'] + ], + JSON_THROW_ON_ERROR + ); + + $object = NullableArray::fromJson($expectedJson); + $this->assertEquals(['one', null, 'three'], $object->nullableStringArray, 'nullable_string_array should match the original data.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nullable_string_array.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/ScalarTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/ScalarTest.php new file mode 100644 index 00000000000..604b7c0b959 --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/ScalarTest.php @@ -0,0 +1,116 @@ + $intFloatArray + */ + #[ArrayType([new Union('integer', 'float')])] + #[JsonProperty('int_float_array')] + public array $intFloatArray; + + /** + * @var array $floatArray + */ + #[ArrayType(['float'])] + #[JsonProperty('float_array')] + public array $floatArray; + + /** + * @var bool|null $nullableBooleanProperty + */ + #[JsonProperty('nullable_boolean_property')] + public ?bool $nullableBooleanProperty; + + /** + * @param array{ + * integerProperty: int, + * floatProperty: float, + * otherFloatProperty: float, + * booleanProperty: bool, + * stringProperty: string, + * intFloatArray: array, + * floatArray: array, + * nullableBooleanProperty?: bool|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->integerProperty = $values['integerProperty']; + $this->floatProperty = $values['floatProperty']; + $this->otherFloatProperty = $values['otherFloatProperty']; + $this->booleanProperty = $values['booleanProperty']; + $this->stringProperty = $values['stringProperty']; + $this->intFloatArray = $values['intFloatArray']; + $this->floatArray = $values['floatArray']; + $this->nullableBooleanProperty = $values['nullableBooleanProperty'] ?? null; + } +} + +class ScalarTest extends TestCase +{ + public function testAllScalarTypesIncludingFloat(): void + { + $expectedJson = json_encode( + [ + 'integer_property' => 42, + 'float_property' => 3.14159, + 'other_float_property' => 3, + 'boolean_property' => true, + 'string_property' => 'Hello, World!', + 'int_float_array' => [1, 2.5, 3, 4.75], + 'float_array' => [1, 2, 3, 4] // Ensure we handle "integer-looking" floats + ], + JSON_THROW_ON_ERROR + ); + + $object = Scalar::fromJson($expectedJson); + $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); + $this->assertEquals(3.14159, $object->floatProperty, 'float_property should be 3.14159.'); + $this->assertTrue($object->booleanProperty, 'boolean_property should be true.'); + $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); + $this->assertNull($object->nullableBooleanProperty, 'nullable_boolean_property should be null.'); + $this->assertEquals([1, 2.5, 3, 4.75], $object->intFloatArray, 'int_float_array should match the original data.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTest.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/TraitTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/TraitTest.php new file mode 100644 index 00000000000..837f239115f --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/TraitTest.php @@ -0,0 +1,60 @@ +integerProperty = $values['integerProperty']; + $this->stringProperty = $values['stringProperty']; + } +} + +class TraitTest extends TestCase +{ + public function testTraitPropertyAndString(): void + { + $expectedJson = json_encode( + [ + 'integer_property' => 42, + 'string_property' => 'Hello, World!', + ], + JSON_THROW_ON_ERROR + ); + + $object = TypeWithTrait::fromJson($expectedJson); + $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); + $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTestWithTrait.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/UnionArrayTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/UnionArrayTest.php new file mode 100644 index 00000000000..09933d2321d --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/UnionArrayTest.php @@ -0,0 +1,57 @@ + $mixedDates + */ + #[ArrayType(['integer' => new Union('datetime', 'string', 'null')])] + #[JsonProperty('mixed_dates')] + public array $mixedDates; + + /** + * @param array{ + * mixedDates: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->mixedDates = $values['mixedDates']; + } +} + +class UnionArrayTest extends TestCase +{ + public function testUnionArray(): void + { + $expectedJson = json_encode( + [ + 'mixed_dates' => [ + 1 => '2023-01-01T12:00:00+00:00', + 2 => null, + 3 => 'Some String' + ] + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionArray::fromJson($expectedJson); + $this->assertInstanceOf(DateTime::class, $object->mixedDates[1], 'mixed_dates[1] should be a DateTime instance.'); + $this->assertEquals('2023-01-01 12:00:00', $object->mixedDates[1]->format('Y-m-d H:i:s'), 'mixed_dates[1] should have the correct datetime.'); + $this->assertNull($object->mixedDates[2], 'mixed_dates[2] should be null.'); + $this->assertEquals('Some String', $object->mixedDates[3], 'mixed_dates[3] should be "Some String".'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for mixed_dates.'); + } +} diff --git a/seed/php-model/inline-types/tests/Seed/Core/Json/UnionPropertyTest.php b/seed/php-model/inline-types/tests/Seed/Core/Json/UnionPropertyTest.php new file mode 100644 index 00000000000..3119baace62 --- /dev/null +++ b/seed/php-model/inline-types/tests/Seed/Core/Json/UnionPropertyTest.php @@ -0,0 +1,115 @@ + 'integer'], UnionProperty::class)] + #[JsonProperty('complexUnion')] + public mixed $complexUnion; + + /** + * @param array{ + * complexUnion: string|int|null|array|UnionProperty + * } $values + */ + public function __construct( + array $values, + ) { + $this->complexUnion = $values['complexUnion']; + } +} + +class UnionPropertyTest extends TestCase +{ + public function testWithMapOfIntToInt(): void + { + $expectedJson = json_encode( + [ + 'complexUnion' => [1 => 100, 2 => 200] + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsArray($object->complexUnion, 'complexUnion should be an array.'); + $this->assertEquals([1 => 100, 2 => 200], $object->complexUnion, 'complexUnion should match the original map of int => int.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithNestedUnionPropertyType(): void + { + $expectedJson = json_encode( + [ + 'complexUnion' => new UnionProperty( + [ + 'complexUnion' => 'Nested String' + ] + ) + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertInstanceOf(UnionProperty::class, $object->complexUnion, 'complexUnion should be an instance of UnionPropertyType.'); + $this->assertEquals('Nested String', $object->complexUnion->complexUnion, 'Nested complexUnion should match the original value.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithNull(): void + { + $expectedJson = json_encode( + [], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertNull($object->complexUnion, 'complexUnion should be null.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithInteger(): void + { + $expectedJson = json_encode( + [ + 'complexUnion' => 42 + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsInt($object->complexUnion, 'complexUnion should be an integer.'); + $this->assertEquals(42, $object->complexUnion, 'complexUnion should match the original integer.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithString(): void + { + $expectedJson = json_encode( + [ + 'complexUnion' => 'Some String' + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsString($object->complexUnion, 'complexUnion should be a string.'); + $this->assertEquals('Some String', $object->complexUnion, 'complexUnion should match the original string.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } +} diff --git a/seed/php-sdk/inline-types/.github/workflows/ci.yml b/seed/php-sdk/inline-types/.github/workflows/ci.yml new file mode 100644 index 00000000000..258bf33a19f --- /dev/null +++ b/seed/php-sdk/inline-types/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + + - name: Install tools + run: | + composer install + + - name: Build + run: | + composer build + + - name: Analyze + run: | + composer analyze + + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + + - name: Install tools + run: | + composer install + + - name: Run Tests + run: | + composer test \ No newline at end of file diff --git a/seed/php-sdk/inline-types/.gitignore b/seed/php-sdk/inline-types/.gitignore new file mode 100644 index 00000000000..f38efc46ade --- /dev/null +++ b/seed/php-sdk/inline-types/.gitignore @@ -0,0 +1,4 @@ +.php-cs-fixer.cache +.phpunit.result.cache +composer.lock +vendor/ \ No newline at end of file diff --git a/seed/php-sdk/inline-types/.mock/definition/__package__.yml b/seed/php-sdk/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/php-sdk/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/php-sdk/inline-types/.mock/definition/api.yml b/seed/php-sdk/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/php-sdk/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/php-sdk/inline-types/.mock/fern.config.json b/seed/php-sdk/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/php-sdk/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/php-sdk/inline-types/.mock/generators.yml b/seed/php-sdk/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/php-sdk/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/php-sdk/inline-types/composer.json b/seed/php-sdk/inline-types/composer.json new file mode 100644 index 00000000000..5c96c0056e0 --- /dev/null +++ b/seed/php-sdk/inline-types/composer.json @@ -0,0 +1,40 @@ + +{ + "name": "seed/seed", + "version": "0.0.1", + "description": "Seed PHP Library", + "keywords": [ + "seed", + "api", + "sdk" + ], + "license": [], + "require": { + "php": "^8.1", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.9" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.12" + }, + "autoload": { + "psr-4": { + "Seed\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "\\Seed\\Tests\\": "tests/" + } + }, + "scripts": { + "build": [ + "@php -l src", + "@php -l tests" + ], + "test": "phpunit", + "analyze": "phpstan analyze src tests" + } +} diff --git a/seed/php-sdk/inline-types/phpstan.neon b/seed/php-sdk/inline-types/phpstan.neon new file mode 100644 index 00000000000..29a11a92a19 --- /dev/null +++ b/seed/php-sdk/inline-types/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: max + paths: + - src + - tests \ No newline at end of file diff --git a/seed/php-sdk/inline-types/phpunit.xml b/seed/php-sdk/inline-types/phpunit.xml new file mode 100644 index 00000000000..54630a51163 --- /dev/null +++ b/seed/php-sdk/inline-types/phpunit.xml @@ -0,0 +1,7 @@ + + + + tests + + + \ No newline at end of file diff --git a/seed/php-sdk/inline-types/snippet-templates.json b/seed/php-sdk/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/php-sdk/inline-types/snippet.json b/seed/php-sdk/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/php-sdk/inline-types/src/Core/Client/BaseApiRequest.php b/seed/php-sdk/inline-types/src/Core/Client/BaseApiRequest.php new file mode 100644 index 00000000000..5e1283e2b6f --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Client/BaseApiRequest.php @@ -0,0 +1,22 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + */ + public function __construct( + public readonly string $baseUrl, + public readonly string $path, + public readonly HttpMethod $method, + public readonly array $headers = [], + public readonly array $query = [], + ) { + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Client/HttpMethod.php b/seed/php-sdk/inline-types/src/Core/Client/HttpMethod.php new file mode 100644 index 00000000000..b9a3e2d0321 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Client/HttpMethod.php @@ -0,0 +1,12 @@ + $headers + */ + private array $headers; + + /** + * @param ?array{ + * baseUrl?: string, + * client?: ClientInterface, + * headers?: array, + * } $options + */ + public function __construct( + public readonly ?array $options = null, + ) { + $this->client = $this->options['client'] ?? new Client(); + $this->headers = $this->options['headers'] ?? []; + } + + /** + * @throws ClientExceptionInterface + */ + public function sendRequest( + BaseApiRequest $request, + ): ResponseInterface { + $httpRequest = $this->buildRequest($request); + return $this->client->send($httpRequest); + } + + private function buildRequest( + BaseApiRequest $request + ): Request { + $url = $this->buildUrl($request); + $headers = $this->encodeHeaders($request); + $body = $this->encodeRequestBody($request); + return new Request( + $request->method->name, + $url, + $headers, + $body, + ); + } + + /** + * @return array + */ + private function encodeHeaders( + BaseApiRequest $request + ): array { + return match (get_class($request)) { + JsonApiRequest::class => array_merge( + ["Content-Type" => "application/json"], + $this->headers, + $request->headers + ), + MultipartApiRequest::class => array_merge( + $this->headers, + $request->headers + ), + default => throw new InvalidArgumentException('Unsupported request type: ' . get_class($request)), + }; + } + + private function encodeRequestBody( + BaseApiRequest $request + ): ?StreamInterface { + return match (get_class($request)) { + JsonApiRequest::class => $request->body != null ? Utils::streamFor(json_encode($request->body)) : null, + MultipartApiRequest::class => $request->body != null ? new MultipartStream($request->body->toArray()) : null, + default => throw new InvalidArgumentException('Unsupported request type: '.get_class($request)), + }; + } + + private function buildUrl( + BaseApiRequest $request + ): string { + $baseUrl = $request->baseUrl; + $trimmedBaseUrl = rtrim($baseUrl, '/'); + $trimmedBasePath = ltrim($request->path, '/'); + $url = "{$trimmedBaseUrl}/{$trimmedBasePath}"; + + if (!empty($request->query)) { + $url .= '?' . $this->encodeQuery($request->query); + } + + return $url; + } + + /** + * @param array $query + */ + private function encodeQuery( + array $query + ): string { + $parts = []; + foreach ($query as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $parts[] = urlencode($key).'='.$this->encodeQueryValue($item); + } + } else { + $parts[] = urlencode($key).'='.$this->encodeQueryValue($value); + } + } + return implode('&', $parts); + } + + private function encodeQueryValue( + mixed $value + ): string { + if (is_string($value)) { + return urlencode($value); + } + if (is_scalar($value)) { + return urlencode((string)$value); + } + if (is_null($value)) { + return 'null'; + } + // Unreachable, but included for a best effort. + return urlencode(strval(json_encode($value))); + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Json/JsonApiRequest.php b/seed/php-sdk/inline-types/src/Core/Json/JsonApiRequest.php new file mode 100644 index 00000000000..8fdf493606e --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Json/JsonApiRequest.php @@ -0,0 +1,28 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + * @param mixed|null $body The JSON request body (optional) + */ + public function __construct( + string $baseUrl, + string $path, + HttpMethod $method, + array $headers = [], + array $query = [], + public readonly mixed $body = null + ) { + parent::__construct($baseUrl, $path, $method, $headers, $query); + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Json/JsonDecoder.php b/seed/php-sdk/inline-types/src/Core/Json/JsonDecoder.php new file mode 100644 index 00000000000..2ddff027348 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Json/JsonDecoder.php @@ -0,0 +1,161 @@ + $type The type definition for deserialization. + * @return mixed[]|array The deserialized array. + * @throws JsonException If the decoded value is not an array. + */ + public static function decodeArray(string $json, array $type): array + { + $decoded = self::decode($json); + if (!is_array($decoded)) { + throw new JsonException("Unexpected non-array json value: " . $json); + } + return JsonDeserializer::deserializeArray($decoded, $type); + } + + /** + * Decodes a JSON string and deserializes it based on the provided union type definition. + * + * @param string $json The JSON string to decode. + * @param Union $union The union type definition for deserialization. + * @return mixed The deserialized value. + * @throws JsonException If the deserialization for all types in the union fails. + */ + public static function decodeUnion(string $json, Union $union): mixed + { + $decoded = self::decode($json); + return JsonDeserializer::deserializeUnion($decoded, $union); + } + /** + * Decodes a JSON string and returns a mixed. + * + * @param string $json The JSON string to decode. + * @return mixed The decoded mixed. + * @throws JsonException If the decoded value is not an mixed. + */ + public static function decodeMixed(string $json): mixed + { + return self::decode($json); + } + + /** + * Decodes a JSON string into a PHP value. + * + * @param string $json The JSON string to decode. + * @return mixed The decoded value. + * @throws JsonException If an error occurs during JSON decoding. + */ + public static function decode(string $json): mixed + { + return json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Json/JsonDeserializer.php b/seed/php-sdk/inline-types/src/Core/Json/JsonDeserializer.php new file mode 100644 index 00000000000..5f0ca2d7ed0 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Json/JsonDeserializer.php @@ -0,0 +1,204 @@ + $data The array to be deserialized. + * @param mixed[]|array $type The type definition from the annotation. + * @return mixed[]|array The deserialized array. + * @throws JsonException If deserialization fails. + */ + public static function deserializeArray(array $data, array $type): array + { + return Utils::isMapType($type) + ? self::deserializeMap($data, $type) + : self::deserializeList($data, $type); + } + + /** + * Deserializes a value based on its type definition. + * + * @param mixed $data The data to deserialize. + * @param mixed $type The type definition. + * @return mixed The deserialized value. + * @throws JsonException If deserialization fails. + */ + private static function deserializeValue(mixed $data, mixed $type): mixed + { + if ($type instanceof Union) { + return self::deserializeUnion($data, $type); + } + + if (is_array($type)) { + return self::deserializeArray((array)$data, $type); + } + + if (gettype($type) != "string") { + throw new JsonException("Unexpected non-string type."); + } + + return self::deserializeSingleValue($data, $type); + } + + /** + * Deserializes a value based on the possible types in a union type definition. + * + * @param mixed $data The data to deserialize. + * @param Union $type The union type definition. + * @return mixed The deserialized value. + * @throws JsonException If none of the union types can successfully deserialize the value. + */ + public static function deserializeUnion(mixed $data, Union $type): mixed + { + foreach ($type->types as $unionType) { + try { + return self::deserializeValue($data, $unionType); + } catch (Exception) { + continue; + } + } + $readableType = Utils::getReadableType($data); + throw new JsonException( + "Cannot deserialize value of type $readableType with any of the union types: " . $type + ); + } + + /** + * Deserializes a single value based on its expected type. + * + * @param mixed $data The data to deserialize. + * @param string $type The expected type. + * @return mixed The deserialized value. + * @throws JsonException If deserialization fails. + */ + private static function deserializeSingleValue(mixed $data, string $type): mixed + { + if ($type === 'null' && $data === null) { + return null; + } + + if ($type === 'date' && is_string($data)) { + return self::deserializeDate($data); + } + + if ($type === 'datetime' && is_string($data)) { + return self::deserializeDateTime($data); + } + + if ($type === 'mixed') { + return $data; + } + + if (class_exists($type) && is_array($data)) { + return self::deserializeObject($data, $type); + } + + // Handle floats as a special case since gettype($data) returns "double" for float values in PHP, and because + // floats make come through from json_decoded as integers + if ($type === 'float' && (is_numeric($data))) { + return (float) $data; + } + + if (gettype($data) === $type) { + return $data; + } + + throw new JsonException("Unable to deserialize value of type '" . gettype($data) . "' as '$type'."); + } + + /** + * Deserializes an array into an object of the given type. + * + * @param array $data The data to deserialize. + * @param string $type The class name of the object to deserialize into. + * + * @return object The deserialized object. + * + * @throws JsonException If the type does not implement JsonSerializableType. + */ + public static function deserializeObject(array $data, string $type): object + { + if (!is_subclass_of($type, JsonSerializableType::class)) { + throw new JsonException("$type is not a subclass of JsonSerializableType."); + } + return $type::jsonDeserialize($data); + } + + /** + * Deserializes a map (associative array) with defined key and value types. + * + * @param array $data The associative array to deserialize. + * @param array $type The type definition for the map. + * @return array The deserialized map. + * @throws JsonException If deserialization fails. + */ + private static function deserializeMap(array $data, array $type): array + { + $keyType = array_key_first($type); + $valueType = $type[$keyType]; + $result = []; + + foreach ($data as $key => $item) { + $key = Utils::castKey($key, (string)$keyType); + $result[$key] = self::deserializeValue($item, $valueType); + } + + return $result; + } + + /** + * Deserializes a list (indexed array) with a defined value type. + * + * @param array $data The list to deserialize. + * @param array $type The type definition for the list. + * @return array The deserialized list. + * @throws JsonException If deserialization fails. + */ + private static function deserializeList(array $data, array $type): array + { + $valueType = $type[0]; + return array_map(fn ($item) => self::deserializeValue($item, $valueType), $data); + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Json/JsonEncoder.php b/seed/php-sdk/inline-types/src/Core/Json/JsonEncoder.php new file mode 100644 index 00000000000..0dbf3fcc994 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Json/JsonEncoder.php @@ -0,0 +1,20 @@ +jsonSerialize(); + $encoded = JsonEncoder::encode($serializedObject); + if (!$encoded) { + throw new Exception("Could not encode type"); + } + return $encoded; + } + + /** + * Serializes the object to an array. + * + * @return mixed[] Array representation of the object. + * @throws JsonException If serialization fails. + */ + public function jsonSerialize(): array + { + $result = []; + $reflectionClass = new \ReflectionClass($this); + + foreach ($reflectionClass->getProperties() as $property) { + $jsonKey = self::getJsonKey($property); + if ($jsonKey == null) { + continue; + } + $value = $property->getValue($this); + + // Handle DateTime properties + $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; + if ($dateTypeAttr && $value instanceof DateTime) { + $dateType = $dateTypeAttr->newInstance()->type; + $value = ($dateType === Date::TYPE_DATE) + ? JsonSerializer::serializeDate($value) + : JsonSerializer::serializeDateTime($value); + } + + // Handle Union annotations + $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; + if ($unionTypeAttr) { + $unionType = $unionTypeAttr->newInstance(); + $value = JsonSerializer::serializeUnion($value, $unionType); + } + + // Handle arrays with type annotations + $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; + if ($arrayTypeAttr && is_array($value)) { + $arrayType = $arrayTypeAttr->newInstance()->type; + $value = JsonSerializer::serializeArray($value, $arrayType); + } + + // Handle object + if (is_object($value)) { + $value = JsonSerializer::serializeObject($value); + } + + if ($value !== null) { + $result[$jsonKey] = $value; + } + } + + return $result; + } + + /** + * Deserializes a JSON string into an instance of the calling class. + * + * @param string $json JSON string to deserialize. + * @return static Deserialized object. + * @throws JsonException If decoding fails or the result is not an array. + * @throws Exception If deserialization fails. + */ + public static function fromJson(string $json): static + { + $decodedJson = JsonDecoder::decode($json); + if (!is_array($decodedJson)) { + throw new JsonException("Unexpected non-array decoded type: " . gettype($decodedJson)); + } + return self::jsonDeserialize($decodedJson); + } + + /** + * Deserializes an array into an instance of the calling class. + * + * @param array $data Array data to deserialize. + * @return static Deserialized object. + * @throws JsonException If deserialization fails. + */ + public static function jsonDeserialize(array $data): static + { + $reflectionClass = new \ReflectionClass(static::class); + $constructor = $reflectionClass->getConstructor(); + + if ($constructor === null) { + throw new JsonException("No constructor found."); + } + + $args = []; + foreach ($reflectionClass->getProperties() as $property) { + $jsonKey = self::getJsonKey($property) ?? $property->getName(); + + if (array_key_exists($jsonKey, $data)) { + $value = $data[$jsonKey]; + + // Handle Date annotation + $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; + if ($dateTypeAttr) { + $dateType = $dateTypeAttr->newInstance()->type; + if (!is_string($value)) { + throw new JsonException("Unexpected non-string type for date."); + } + $value = ($dateType === Date::TYPE_DATE) + ? JsonDeserializer::deserializeDate($value) + : JsonDeserializer::deserializeDateTime($value); + } + + // Handle Array annotation + $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; + if (is_array($value) && $arrayTypeAttr) { + $arrayType = $arrayTypeAttr->newInstance()->type; + $value = JsonDeserializer::deserializeArray($value, $arrayType); + } + + // Handle Union annotations + $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; + if ($unionTypeAttr) { + $unionType = $unionTypeAttr->newInstance(); + $value = JsonDeserializer::deserializeUnion($value, $unionType); + } + + // Handle object + $type = $property->getType(); + if (is_array($value) && $type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $value = JsonDeserializer::deserializeObject($value, $type->getName()); + } + + $args[$property->getName()] = $value; + } else { + $defaultValue = $property->getDefaultValue() ?? null; + $args[$property->getName()] = $defaultValue; + } + } + // @phpstan-ignore-next-line + return new static($args); + } + + /** + * Retrieves the JSON key associated with a property. + * + * @param ReflectionProperty $property The reflection property. + * @return ?string The JSON key, or null if not available. + */ + private static function getJsonKey(ReflectionProperty $property): ?string + { + $jsonPropertyAttr = $property->getAttributes(JsonProperty::class)[0] ?? null; + return $jsonPropertyAttr?->newInstance()?->name; + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Json/JsonSerializer.php b/seed/php-sdk/inline-types/src/Core/Json/JsonSerializer.php new file mode 100644 index 00000000000..7dd6fe517af --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Json/JsonSerializer.php @@ -0,0 +1,192 @@ +format(Constant::DateFormat); + } + + /** + * Serializes a DateTime object into a string using the date-time format. + * + * @param DateTime $date The DateTime object to serialize. + * @return string The serialized date-time string. + */ + public static function serializeDateTime(DateTime $date): string + { + return $date->format(Constant::DateTimeFormat); + } + + /** + * Serializes an array based on type annotations (either a list or map). + * + * @param mixed[]|array $data The array to be serialized. + * @param mixed[]|array $type The type definition from the annotation. + * @return mixed[]|array The serialized array. + * @throws JsonException If serialization fails. + */ + public static function serializeArray(array $data, array $type): array + { + return Utils::isMapType($type) + ? self::serializeMap($data, $type) + : self::serializeList($data, $type); + } + + /** + * Serializes a value based on its type definition. + * + * @param mixed $data The value to serialize. + * @param mixed $type The type definition. + * @return mixed The serialized value. + * @throws JsonException If serialization fails. + */ + private static function serializeValue(mixed $data, mixed $type): mixed + { + if ($type instanceof Union) { + return self::serializeUnion($data, $type); + } + + if (is_array($type)) { + return self::serializeArray((array)$data, $type); + } + + if (gettype($type) != "string") { + throw new JsonException("Unexpected non-string type."); + } + + return self::serializeSingleValue($data, $type); + } + + /** + * Serializes a value for a union type definition. + * + * @param mixed $data The value to serialize. + * @param Union $unionType The union type definition. + * @return mixed The serialized value. + * @throws JsonException If serialization fails for all union types. + */ + public static function serializeUnion(mixed $data, Union $unionType): mixed + { + foreach ($unionType->types as $type) { + try { + return self::serializeValue($data, $type); + } catch (Exception) { + // Try the next type in the union + continue; + } + } + $readableType = Utils::getReadableType($data); + throw new JsonException( + "Cannot serialize value of type $readableType with any of the union types: " . $unionType + ); + } + + /** + * Serializes a single value based on its type. + * + * @param mixed $data The value to serialize. + * @param string $type The expected type. + * @return mixed The serialized value. + * @throws JsonException If serialization fails. + */ + private static function serializeSingleValue(mixed $data, string $type): mixed + { + if ($type === 'null' && $data === null) { + return null; + } + + if (($type === 'date' || $type === 'datetime') && $data instanceof DateTime) { + return $type === 'date' ? self::serializeDate($data) : self::serializeDateTime($data); + } + + if ($type === 'mixed') { + return $data; + } + + if (class_exists($type) && $data instanceof $type) { + return self::serializeObject($data); + } + + // Handle floats as a special case since gettype($data) returns "double" for float values in PHP. + if ($type === 'float' && is_float($data)) { + return $data; + } + + if (gettype($data) === $type) { + return $data; + } + + throw new JsonException("Unable to serialize value of type '" . gettype($data) . "' as '$type'."); + } + + /** + * Serializes an object to a JSON-serializable format. + * + * @param object $data The object to serialize. + * @return mixed The serialized data. + * @throws JsonException If the object does not implement JsonSerializable. + */ + public static function serializeObject(object $data): mixed + { + if (!is_subclass_of($data, JsonSerializable::class)) { + $type = get_class($data); + throw new JsonException("Class $type must implement JsonSerializable."); + } + return $data->jsonSerialize(); + } + + /** + * Serializes a map (associative array) with defined key and value types. + * + * @param array $data The associative array to serialize. + * @param array $type The type definition for the map. + * @return array The serialized map. + * @throws JsonException If serialization fails. + */ + private static function serializeMap(array $data, array $type): array + { + $keyType = array_key_first($type); + if ($keyType === null) { + throw new JsonException("Unexpected no key in ArrayType."); + } + $valueType = $type[$keyType]; + $result = []; + + foreach ($data as $key => $item) { + $key = Utils::castKey($key, $keyType); + $result[$key] = self::serializeValue($item, $valueType); + } + + return $result; + } + + /** + * Serializes a list (indexed array) where only the value type is defined. + * + * @param array $data The list to serialize. + * @param array $type The type definition for the list. + * @return array The serialized list. + * @throws JsonException If serialization fails. + */ + private static function serializeList(array $data, array $type): array + { + $valueType = $type[0]; + return array_map(fn ($item) => self::serializeValue($item, $valueType), $data); + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Json/Utils.php b/seed/php-sdk/inline-types/src/Core/Json/Utils.php new file mode 100644 index 00000000000..7577c058916 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Json/Utils.php @@ -0,0 +1,61 @@ + $type The type definition from the annotation. + * @return bool True if the type is a map, false if it's a list. + */ + public static function isMapType(array $type): bool + { + return count($type) === 1 && !array_is_list($type); + } + + /** + * Casts the key to the appropriate type based on the key type. + * + * @param mixed $key The key to be cast. + * @param string $keyType The type to cast the key to ('string', 'integer', 'float'). + * @return mixed The casted key. + * @throws JsonException + */ + public static function castKey(mixed $key, string $keyType): mixed + { + if (!is_scalar($key)) { + throw new JsonException("Key must be a scalar type."); + } + return match ($keyType) { + 'integer' => (int)$key, + 'float' => (float)$key, + 'string' => (string)$key, + default => $key, + }; + } + + /** + * Returns a human-readable representation of the input's type. + * + * @param mixed $input The input value to determine the type of. + * @return string A readable description of the input type. + */ + public static function getReadableType(mixed $input): string + { + if (is_object($input)) { + return get_class($input); + } elseif (is_array($input)) { + return 'array(' . count($input) . ' items)'; + } elseif (is_null($input)) { + return 'null'; + } else { + return gettype($input); + } + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Multipart/MultipartApiRequest.php b/seed/php-sdk/inline-types/src/Core/Multipart/MultipartApiRequest.php new file mode 100644 index 00000000000..7760366456c --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Multipart/MultipartApiRequest.php @@ -0,0 +1,28 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + * @param ?MultipartFormData $body The multipart form data for the request (optional) + */ + public function __construct( + string $baseUrl, + string $path, + HttpMethod $method, + array $headers = [], + array $query = [], + public readonly ?MultipartFormData $body = null + ) { + parent::__construct($baseUrl, $path, $method, $headers, $query); + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Multipart/MultipartFormData.php b/seed/php-sdk/inline-types/src/Core/Multipart/MultipartFormData.php new file mode 100644 index 00000000000..33bb67b05bd --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Multipart/MultipartFormData.php @@ -0,0 +1,61 @@ + + */ + private array $parts = []; + + /** + * Adds a new part to the multipart form data. + * + * @param string $name + * @param string|int|bool|float|StreamInterface $value + * @param ?string $contentType + */ + public function add( + string $name, + string|int|bool|float|StreamInterface $value, + ?string $contentType = null, + ): void { + $headers = $contentType != null ? ['Content-Type' => $contentType] : null; + self::addPart( + new MultipartFormDataPart( + name: $name, + value: $value, + headers: $headers, + ) + ); + } + + /** + * Adds a new part to the multipart form data. + * + * @param MultipartFormDataPart $part + */ + public function addPart(MultipartFormDataPart $part): void + { + $this->parts[] = $part; + } + + /** + * Converts the multipart form data into an array suitable + * for Guzzle's multipart form data. + * + * @return array + * }> + */ + public function toArray(): array + { + return array_map(fn ($part) => $part->toArray(), $this->parts); + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Multipart/MultipartFormDataPart.php b/seed/php-sdk/inline-types/src/Core/Multipart/MultipartFormDataPart.php new file mode 100644 index 00000000000..c158903d84f --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Multipart/MultipartFormDataPart.php @@ -0,0 +1,76 @@ + + */ + private ?array $headers; + + /** + * @param string $name + * @param string|bool|float|int|StreamInterface $value + * @param ?string $filename + * @param ?array $headers + */ + public function __construct( + string $name, + string|bool|float|int|StreamInterface $value, + ?string $filename = null, + ?array $headers = null + ) { + $this->name = $name; + $this->contents = Utils::streamFor($value); + $this->filename = $filename; + $this->headers = $headers; + } + + /** + * Converts the multipart form data part into an array suitable + * for Guzzle's multipart form data. + * + * @return array{ + * name: string, + * contents: StreamInterface, + * filename?: string, + * headers?: array + * } + */ + public function toArray(): array + { + $formData = [ + 'name' => $this->name, + 'contents' => $this->contents, + ]; + + if ($this->filename != null) { + $formData['filename'] = $this->filename; + } + + if ($this->headers != null) { + $formData['headers'] = $this->headers; + } + + return $formData; + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Types/ArrayType.php b/seed/php-sdk/inline-types/src/Core/Types/ArrayType.php new file mode 100644 index 00000000000..a26d29008ec --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Types/ArrayType.php @@ -0,0 +1,16 @@ + 'valueType'] for maps, or ['valueType'] for lists + */ + public function __construct(public array $type) + { + } +} diff --git a/seed/php-sdk/inline-types/src/Core/Types/Constant.php b/seed/php-sdk/inline-types/src/Core/Types/Constant.php new file mode 100644 index 00000000000..5ac4518cc6d --- /dev/null +++ b/seed/php-sdk/inline-types/src/Core/Types/Constant.php @@ -0,0 +1,12 @@ +> The types allowed for this property, which can be strings, arrays, or nested Union types. + */ + public array $types; + + /** + * Constructor for the Union attribute. + * + * @param string|Union|array ...$types The list of types that the property can accept. + * This can include primitive types (e.g., 'string', 'int'), arrays, or other Union instances. + * + * Example: + * ```php + * #[Union('string', 'null', 'date', new Union('boolean', 'int'))] + * ``` + */ + public function __construct(string|Union|array ...$types) + { + $this->types = $types; + } + + /** + * Converts the Union type to a string representation. + * + * @return string A string representation of the union types. + */ + public function __toString(): string + { + return implode(' | ', array_map(function ($type) { + if (is_string($type)) { + return $type; + } elseif ($type instanceof Union) { + return (string) $type; // Recursively handle nested unions + } elseif (is_array($type)) { + return 'array'; // Handle arrays + } + }, $this->types)); + } +} diff --git a/seed/php-sdk/inline-types/src/Exceptions/SeedApiException.php b/seed/php-sdk/inline-types/src/Exceptions/SeedApiException.php new file mode 100644 index 00000000000..41a85392b70 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Exceptions/SeedApiException.php @@ -0,0 +1,53 @@ +body = $body; + parent::__construct($message, $statusCode, $previous); + } + + /** + * Returns the body of the response that triggered the exception. + * + * @return mixed + */ + public function getBody(): mixed + { + return $this->body; + } + + /** + * @return string + */ + public function __toString(): string + { + if (empty($this->body)) { + return "$this->message; Status Code: $this->code\n"; + } + return "$this->message; Status Code: $this->code; Body: " . $this->body . "\n"; + } +} diff --git a/seed/php-sdk/inline-types/src/Exceptions/SeedException.php b/seed/php-sdk/inline-types/src/Exceptions/SeedException.php new file mode 100644 index 00000000000..45703527673 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Exceptions/SeedException.php @@ -0,0 +1,12 @@ +bar = $values['bar']; + $this->foo = $values['foo']; + } +} diff --git a/seed/php-sdk/inline-types/src/SeedClient.php b/seed/php-sdk/inline-types/src/SeedClient.php new file mode 100644 index 00000000000..44d26d3aa50 --- /dev/null +++ b/seed/php-sdk/inline-types/src/SeedClient.php @@ -0,0 +1,95 @@ +, + * } $options + */ + private ?array $options; + + /** + * @var RawClient $client + */ + private RawClient $client; + + /** + * @param ?array{ + * baseUrl?: string, + * client?: ClientInterface, + * headers?: array, + * } $options + */ + public function __construct( + ?array $options = null, + ) { + $defaultHeaders = [ + 'X-Fern-Language' => 'PHP', + 'X-Fern-SDK-Name' => 'Seed', + 'X-Fern-SDK-Version' => '0.0.1', + ]; + + $this->options = $options ?? []; + $this->options['headers'] = array_merge( + $defaultHeaders, + $this->options['headers'] ?? [], + ); + + $this->client = new RawClient( + options: $this->options, + ); + } + + /** + * @param PostRootRequest $request + * @param ?array{ + * baseUrl?: string, + * } $options + * @return RootType1 + * @throws SeedException + * @throws SeedApiException + */ + public function getRoot(PostRootRequest $request, ?array $options = null): RootType1 + { + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', + path: "/root/root", + method: HttpMethod::POST, + body: $request, + ), + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + return RootType1::fromJson($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } +} diff --git a/seed/php-sdk/inline-types/src/Types/InlineEnum.php b/seed/php-sdk/inline-types/src/Types/InlineEnum.php new file mode 100644 index 00000000000..53a2a5b26a0 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Types/InlineEnum.php @@ -0,0 +1,11 @@ +foo = $values['foo']; + $this->bar = $values['bar']; + } +} diff --git a/seed/php-sdk/inline-types/src/Types/InlineType2.php b/seed/php-sdk/inline-types/src/Types/InlineType2.php new file mode 100644 index 00000000000..b731042a72a --- /dev/null +++ b/seed/php-sdk/inline-types/src/Types/InlineType2.php @@ -0,0 +1,26 @@ +baz = $values['baz']; + } +} diff --git a/seed/php-sdk/inline-types/src/Types/NestedInlineType1.php b/seed/php-sdk/inline-types/src/Types/NestedInlineType1.php new file mode 100644 index 00000000000..87166a44664 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Types/NestedInlineType1.php @@ -0,0 +1,42 @@ + $myEnum + */ + #[JsonProperty('myEnum')] + public string $myEnum; + + /** + * @param array{ + * foo: string, + * bar: string, + * myEnum: value-of, + * } $values + */ + public function __construct( + array $values, + ) { + $this->foo = $values['foo']; + $this->bar = $values['bar']; + $this->myEnum = $values['myEnum']; + } +} diff --git a/seed/php-sdk/inline-types/src/Types/RootType1.php b/seed/php-sdk/inline-types/src/Types/RootType1.php new file mode 100644 index 00000000000..d4531d4a1cd --- /dev/null +++ b/seed/php-sdk/inline-types/src/Types/RootType1.php @@ -0,0 +1,34 @@ +foo = $values['foo']; + $this->bar = $values['bar']; + } +} diff --git a/seed/php-sdk/inline-types/src/Utils/File.php b/seed/php-sdk/inline-types/src/Utils/File.php new file mode 100644 index 00000000000..753748138d4 --- /dev/null +++ b/seed/php-sdk/inline-types/src/Utils/File.php @@ -0,0 +1,125 @@ +filename = $filename; + $this->contentType = $contentType; + $this->stream = $stream; + } + + /** + * Creates a File instance from a filepath. + * + * @param string $filepath + * @param ?string $filename + * @param ?string $contentType + * @return File + * @throws Exception + */ + public static function createFromFilepath( + string $filepath, + ?string $filename = null, + ?string $contentType = null, + ): File { + $resource = fopen($filepath, 'r'); + if (!$resource) { + throw new Exception("Unable to open file $filepath"); + } + $stream = Utils::streamFor($resource); + if (!$stream->isReadable()) { + throw new Exception("File $filepath is not readable"); + } + return new self( + stream: $stream, + filename: $filename ?? basename($filepath), + contentType: $contentType, + ); + } + + /** + * Creates a File instance from a string. + * + * @param string $content + * @param ?string $filename + * @param ?string $contentType + * @return File + */ + public static function createFromString( + string $content, + ?string $filename, + ?string $contentType = null, + ): File { + return new self( + stream: Utils::streamFor($content), + filename: $filename, + contentType: $contentType, + ); + } + + /** + * Maps this File into a multipart form data part. + * + * @param string $name The name of the mutlipart form data part. + * @param ?string $contentType Overrides the Content-Type associated with the file, if any. + * @return MultipartFormDataPart + */ + public function toMultipartFormDataPart(string $name, ?string $contentType = null): MultipartFormDataPart + { + $contentType ??= $this->contentType; + $headers = $contentType != null + ? ['Content-Type' => $contentType] + : null; + + return new MultipartFormDataPart( + name: $name, + value: $this->stream, + filename: $this->filename, + headers: $headers, + ); + } + + /** + * Closes the file stream. + */ + public function close(): void + { + $this->stream->close(); + } + + /** + * Destructor to ensure stream is closed. + */ + public function __destruct() + { + $this->close(); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Client/RawClientTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Client/RawClientTest.php new file mode 100644 index 00000000000..a1052cff3a5 --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Client/RawClientTest.php @@ -0,0 +1,101 @@ +mockHandler = new MockHandler(); + $handlerStack = HandlerStack::create($this->mockHandler); + $client = new Client(['handler' => $handlerStack]); + $this->rawClient = new RawClient(['client' => $client]); + } + + public function testHeaders(): void + { + $this->mockHandler->append(new Response(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ['X-Custom-Header' => 'TestValue'] + ); + + $this->sendRequest($request); + + $lastRequest = $this->mockHandler->getLastRequest(); + assert($lastRequest instanceof RequestInterface); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('TestValue', $lastRequest->getHeaderLine('X-Custom-Header')); + } + + public function testQueryParameters(): void + { + $this->mockHandler->append(new Response(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + [], + ['param1' => 'value1', 'param2' => ['a', 'b'], 'param3' => 'true'] + ); + + $this->sendRequest($request); + + $lastRequest = $this->mockHandler->getLastRequest(); + assert($lastRequest instanceof RequestInterface); + $this->assertEquals( + 'https://api.example.com/test?param1=value1¶m2=a¶m2=b¶m3=true', + (string)$lastRequest->getUri() + ); + } + + public function testJsonBody(): void + { + $this->mockHandler->append(new Response(200)); + + $body = ['key' => 'value']; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + $body + ); + + $this->sendRequest($request); + + $lastRequest = $this->mockHandler->getLastRequest(); + assert($lastRequest instanceof RequestInterface); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals(json_encode($body), (string)$lastRequest->getBody()); + } + + private function sendRequest(BaseApiRequest $request): void + { + try { + $this->rawClient->sendRequest($request); + } catch (\Throwable $e) { + $this->fail('An exception was thrown: ' . $e->getMessage()); + } + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/DateArrayTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/DateArrayTest.php new file mode 100644 index 00000000000..a72cfdbdd22 --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/DateArrayTest.php @@ -0,0 +1,54 @@ +dates = $values['dates']; + } +} + +class DateArrayTest extends TestCase +{ + public function testDateTimeInArrays(): void + { + $expectedJson = json_encode( + [ + 'dates' => ['2023-01-01', '2023-02-01', '2023-03-01'] + ], + JSON_THROW_ON_ERROR + ); + + $object = DateArray::fromJson($expectedJson); + $this->assertInstanceOf(DateTime::class, $object->dates[0], 'dates[0] should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->dates[0]->format('Y-m-d'), 'dates[0] should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->dates[1], 'dates[1] should be a DateTime instance.'); + $this->assertEquals('2023-02-01', $object->dates[1]->format('Y-m-d'), 'dates[1] should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->dates[2], 'dates[2] should be a DateTime instance.'); + $this->assertEquals('2023-03-01', $object->dates[2]->format('Y-m-d'), 'dates[2] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for dates array.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/EmptyArrayTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/EmptyArrayTest.php new file mode 100644 index 00000000000..d243a08916d --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/EmptyArrayTest.php @@ -0,0 +1,71 @@ + $emptyMapArray + */ + #[JsonProperty('empty_map_array')] + #[ArrayType(['integer' => new Union('string', 'null')])] + public array $emptyMapArray; + + /** + * @var array $emptyDatesArray + */ + #[ArrayType([new Union('date', 'null')])] + #[JsonProperty('empty_dates_array')] + public array $emptyDatesArray; + + /** + * @param array{ + * emptyStringArray: string[], + * emptyMapArray: array, + * emptyDatesArray: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->emptyStringArray = $values['emptyStringArray']; + $this->emptyMapArray = $values['emptyMapArray']; + $this->emptyDatesArray = $values['emptyDatesArray']; + } +} + +class EmptyArrayTest extends TestCase +{ + public function testEmptyArray(): void + { + $expectedJson = json_encode( + [ + 'empty_string_array' => [], + 'empty_map_array' => [], + 'empty_dates_array' => [] + ], + JSON_THROW_ON_ERROR + ); + + $object = EmptyArray::fromJson($expectedJson); + $this->assertEmpty($object->emptyStringArray, 'empty_string_array should be empty.'); + $this->assertEmpty($object->emptyMapArray, 'empty_map_array should be empty.'); + $this->assertEmpty($object->emptyDatesArray, 'empty_dates_array should be empty.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for EmptyArraysType.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/EnumTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/EnumTest.php new file mode 100644 index 00000000000..bf83d5b8ab0 --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/EnumTest.php @@ -0,0 +1,76 @@ +value; + } +} + +class ShapeType extends JsonSerializableType +{ + /** + * @var Shape $shape + */ + #[JsonProperty('shape')] + public Shape $shape; + + /** + * @var Shape[] $shapes + */ + #[ArrayType([Shape::class])] + #[JsonProperty('shapes')] + public array $shapes; + + /** + * @param Shape $shape + * @param Shape[] $shapes + */ + public function __construct( + Shape $shape, + array $shapes, + ) { + $this->shape = $shape; + $this->shapes = $shapes; + } +} + +class EnumTest extends TestCase +{ + public function testEnumSerialization(): void + { + $object = new ShapeType( + Shape::Circle, + [Shape::Square, Shape::Circle, Shape::Triangle] + ); + + $expectedJson = json_encode([ + 'shape' => 'CIRCLE', + 'shapes' => ['SQUARE', 'CIRCLE', 'TRIANGLE'] + ], JSON_THROW_ON_ERROR); + + $actualJson = $object->toJson(); + + $this->assertJsonStringEqualsJsonString( + $expectedJson, + $actualJson, + 'Serialized JSON does not match expected JSON for shape and shapes properties.' + ); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/ExhaustiveTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/ExhaustiveTest.php new file mode 100644 index 00000000000..f542d6a535d --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/ExhaustiveTest.php @@ -0,0 +1,197 @@ +nestedProperty = $values['nestedProperty']; + } +} + +class Type extends JsonSerializableType +{ + /** + * @var Nested nestedType + */ + #[JsonProperty('nested_type')] + public Nested $nestedType; /** + + * @var string $simpleProperty + */ + #[JsonProperty('simple_property')] + public string $simpleProperty; + + /** + * @var DateTime $dateProperty + */ + #[Date(Date::TYPE_DATE)] + #[JsonProperty('date_property')] + public DateTime $dateProperty; + + /** + * @var DateTime $datetimeProperty + */ + #[Date(Date::TYPE_DATETIME)] + #[JsonProperty('datetime_property')] + public DateTime $datetimeProperty; + + /** + * @var array $stringArray + */ + #[ArrayType(['string'])] + #[JsonProperty('string_array')] + public array $stringArray; + + /** + * @var array $mapProperty + */ + #[ArrayType(['string' => 'integer'])] + #[JsonProperty('map_property')] + public array $mapProperty; + + /** + * @var array $objectArray + */ + #[ArrayType(['integer' => new Union(Nested::class, 'null')])] + #[JsonProperty('object_array')] + public array $objectArray; + + /** + * @var array> $nestedArray + */ + #[ArrayType(['integer' => ['integer' => new Union('string', 'null')]])] + #[JsonProperty('nested_array')] + public array $nestedArray; + + /** + * @var array $datesArray + */ + #[ArrayType([new Union('date', 'null')])] + #[JsonProperty('dates_array')] + public array $datesArray; + + /** + * @var string|null $nullableProperty + */ + #[JsonProperty('nullable_property')] + public ?string $nullableProperty; + + /** + * @param array{ + * nestedType: Nested, + * simpleProperty: string, + * dateProperty: DateTime, + * datetimeProperty: DateTime, + * stringArray: array, + * mapProperty: array, + * objectArray: array, + * nestedArray: array>, + * datesArray: array, + * nullableProperty?: string|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nestedType = $values['nestedType']; + $this->simpleProperty = $values['simpleProperty']; + $this->dateProperty = $values['dateProperty']; + $this->datetimeProperty = $values['datetimeProperty']; + $this->stringArray = $values['stringArray']; + $this->mapProperty = $values['mapProperty']; + $this->objectArray = $values['objectArray']; + $this->nestedArray = $values['nestedArray']; + $this->datesArray = $values['datesArray']; + $this->nullableProperty = $values['nullableProperty'] ?? null; + } +} + +class ExhaustiveTest extends TestCase +{ + /** + * Test serialization and deserialization of all types in Type. + */ + public function testExhaustive(): void + { + $expectedJson = json_encode( + [ + 'nested_type' => ['nested_property' => '1995-07-20'], + 'simple_property' => 'Test String', + // Omit 'nullable_property' to test null serialization + 'date_property' => '2023-01-01', + 'datetime_property' => '2023-01-01T12:34:56+00:00', + 'string_array' => ['one', 'two', 'three'], + 'map_property' => ['key1' => 1, 'key2' => 2], + 'object_array' => [ + 1 => ['nested_property' => '2021-07-20'], + 2 => null, // Testing nullable objects in array + ], + 'nested_array' => [ + 1 => [1 => 'value1', 2 => null], // Testing nullable strings in nested array + 2 => [3 => 'value3', 4 => 'value4'] + ], + 'dates_array' => ['2023-01-01', null, '2023-03-01'] // Testing nullable dates in array> + ], + JSON_THROW_ON_ERROR + ); + + $object = Type::fromJson($expectedJson); + + // Check that nullable property is null and not included in JSON + $this->assertNull($object->nullableProperty, 'Nullable property should be null.'); + + // Check date properties + $this->assertInstanceOf(DateTime::class, $object->dateProperty, 'date_property should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->dateProperty->format('Y-m-d'), 'date_property should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->datetimeProperty, 'datetime_property should be a DateTime instance.'); + $this->assertEquals('2023-01-01 12:34:56', $object->datetimeProperty->format('Y-m-d H:i:s'), 'datetime_property should have the correct datetime.'); + + // Check scalar arrays + $this->assertEquals(['one', 'two', 'three'], $object->stringArray, 'string_array should match the original data.'); + $this->assertEquals(['key1' => 1, 'key2' => 2], $object->mapProperty, 'map_property should match the original data.'); + + // Check object array with nullable elements + $this->assertInstanceOf(Nested::class, $object->objectArray[1], 'object_array[1] should be an instance of TestNestedType1.'); + $this->assertEquals('2021-07-20', $object->objectArray[1]->nestedProperty->format('Y-m-d'), 'object_array[1]->nestedProperty should match the original data.'); + $this->assertNull($object->objectArray[2], 'object_array[2] should be null.'); + + // Check nested array with nullable strings + $this->assertEquals('value1', $object->nestedArray[1][1], 'nested_array[1][1] should match the original data.'); + $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); + $this->assertEquals('value3', $object->nestedArray[2][3], 'nested_array[2][3] should match the original data.'); + $this->assertEquals('value4', $object->nestedArray[2][4], 'nested_array[2][4] should match the original data.'); + + // Check dates array with nullable DateTime objects + $this->assertInstanceOf(DateTime::class, $object->datesArray[0], 'dates_array[0] should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->datesArray[0]->format('Y-m-d'), 'dates_array[0] should have the correct date.'); + $this->assertNull($object->datesArray[1], 'dates_array[1] should be null.'); + $this->assertInstanceOf(DateTime::class, $object->datesArray[2], 'dates_array[2] should be a DateTime instance.'); + $this->assertEquals('2023-03-01', $object->datesArray[2]->format('Y-m-d'), 'dates_array[2] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'The serialized JSON does not match the original JSON.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/InvalidTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/InvalidTest.php new file mode 100644 index 00000000000..7d1d79406a5 --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/InvalidTest.php @@ -0,0 +1,42 @@ +integerProperty = $values['integerProperty']; + } +} + +class InvalidTest extends TestCase +{ + public function testInvalidJsonThrowsException(): void + { + $this->expectException(\TypeError::class); + $json = json_encode( + [ + 'integer_property' => 'not_an_integer' + ], + JSON_THROW_ON_ERROR + ); + Invalid::fromJson($json); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/NestedUnionArrayTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/NestedUnionArrayTest.php new file mode 100644 index 00000000000..0fcdd06667e --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/NestedUnionArrayTest.php @@ -0,0 +1,89 @@ +nestedProperty = $values['nestedProperty']; + } +} + +class NestedUnionArray extends JsonSerializableType +{ + /** + * @var array> $nestedArray + */ + #[ArrayType(['integer' => ['integer' => new Union(UnionObject::class, 'null', 'date')]])] + #[JsonProperty('nested_array')] + public array $nestedArray; + + /** + * @param array{ + * nestedArray: array>, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nestedArray = $values['nestedArray']; + } +} + +class NestedUnionArrayTest extends TestCase +{ + public function testNestedUnionArray(): void + { + $expectedJson = json_encode( + [ + 'nested_array' => [ + 1 => [ + 1 => ['nested_property' => 'Nested One'], + 2 => null, + 4 => '2023-01-02' + ], + 2 => [ + 5 => ['nested_property' => 'Nested Two'], + 7 => '2023-02-02' + ] + ] + ], + JSON_THROW_ON_ERROR + ); + + $object = NestedUnionArray::fromJson($expectedJson); + $this->assertInstanceOf(UnionObject::class, $object->nestedArray[1][1], 'nested_array[1][1] should be an instance of Object.'); + $this->assertEquals('Nested One', $object->nestedArray[1][1]->nestedProperty, 'nested_array[1][1]->nestedProperty should match the original data.'); + $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); + $this->assertInstanceOf(DateTime::class, $object->nestedArray[1][4], 'nested_array[1][4] should be a DateTime instance.'); + $this->assertEquals('2023-01-02T00:00:00+00:00', $object->nestedArray[1][4]->format(Constant::DateTimeFormat), 'nested_array[1][4] should have the correct datetime.'); + $this->assertInstanceOf(UnionObject::class, $object->nestedArray[2][5], 'nested_array[2][5] should be an instance of Object.'); + $this->assertEquals('Nested Two', $object->nestedArray[2][5]->nestedProperty, 'nested_array[2][5]->nestedProperty should match the original data.'); + $this->assertInstanceOf(DateTime::class, $object->nestedArray[2][7], 'nested_array[1][4] should be a DateTime instance.'); + $this->assertEquals('2023-02-02', $object->nestedArray[2][7]->format('Y-m-d'), 'nested_array[1][4] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nested_array.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/NullPropertyTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/NullPropertyTest.php new file mode 100644 index 00000000000..ce20a244282 --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/NullPropertyTest.php @@ -0,0 +1,53 @@ +nonNullProperty = $values['nonNullProperty']; + $this->nullProperty = $values['nullProperty'] ?? null; + } +} + +class NullPropertyTest extends TestCase +{ + public function testNullPropertiesAreOmitted(): void + { + $object = new NullProperty( + [ + "nonNullProperty" => "Test String", + "nullProperty" => null + ] + ); + + $serialized = $object->jsonSerialize(); + $this->assertArrayHasKey('non_null_property', $serialized, 'non_null_property should be present in the serialized JSON.'); + $this->assertArrayNotHasKey('null_property', $serialized, 'null_property should be omitted from the serialized JSON.'); + $this->assertEquals('Test String', $serialized['non_null_property'], 'non_null_property should have the correct value.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/NullableArrayTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/NullableArrayTest.php new file mode 100644 index 00000000000..fe0f19de6b1 --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/NullableArrayTest.php @@ -0,0 +1,49 @@ + $nullableStringArray + */ + #[ArrayType([new Union('string', 'null')])] + #[JsonProperty('nullable_string_array')] + public array $nullableStringArray; + + /** + * @param array{ + * nullableStringArray: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nullableStringArray = $values['nullableStringArray']; + } +} + +class NullableArrayTest extends TestCase +{ + public function testNullableArray(): void + { + $expectedJson = json_encode( + [ + 'nullable_string_array' => ['one', null, 'three'] + ], + JSON_THROW_ON_ERROR + ); + + $object = NullableArray::fromJson($expectedJson); + $this->assertEquals(['one', null, 'three'], $object->nullableStringArray, 'nullable_string_array should match the original data.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nullable_string_array.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/ScalarTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/ScalarTest.php new file mode 100644 index 00000000000..604b7c0b959 --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/ScalarTest.php @@ -0,0 +1,116 @@ + $intFloatArray + */ + #[ArrayType([new Union('integer', 'float')])] + #[JsonProperty('int_float_array')] + public array $intFloatArray; + + /** + * @var array $floatArray + */ + #[ArrayType(['float'])] + #[JsonProperty('float_array')] + public array $floatArray; + + /** + * @var bool|null $nullableBooleanProperty + */ + #[JsonProperty('nullable_boolean_property')] + public ?bool $nullableBooleanProperty; + + /** + * @param array{ + * integerProperty: int, + * floatProperty: float, + * otherFloatProperty: float, + * booleanProperty: bool, + * stringProperty: string, + * intFloatArray: array, + * floatArray: array, + * nullableBooleanProperty?: bool|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->integerProperty = $values['integerProperty']; + $this->floatProperty = $values['floatProperty']; + $this->otherFloatProperty = $values['otherFloatProperty']; + $this->booleanProperty = $values['booleanProperty']; + $this->stringProperty = $values['stringProperty']; + $this->intFloatArray = $values['intFloatArray']; + $this->floatArray = $values['floatArray']; + $this->nullableBooleanProperty = $values['nullableBooleanProperty'] ?? null; + } +} + +class ScalarTest extends TestCase +{ + public function testAllScalarTypesIncludingFloat(): void + { + $expectedJson = json_encode( + [ + 'integer_property' => 42, + 'float_property' => 3.14159, + 'other_float_property' => 3, + 'boolean_property' => true, + 'string_property' => 'Hello, World!', + 'int_float_array' => [1, 2.5, 3, 4.75], + 'float_array' => [1, 2, 3, 4] // Ensure we handle "integer-looking" floats + ], + JSON_THROW_ON_ERROR + ); + + $object = Scalar::fromJson($expectedJson); + $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); + $this->assertEquals(3.14159, $object->floatProperty, 'float_property should be 3.14159.'); + $this->assertTrue($object->booleanProperty, 'boolean_property should be true.'); + $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); + $this->assertNull($object->nullableBooleanProperty, 'nullable_boolean_property should be null.'); + $this->assertEquals([1, 2.5, 3, 4.75], $object->intFloatArray, 'int_float_array should match the original data.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTest.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/TraitTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/TraitTest.php new file mode 100644 index 00000000000..837f239115f --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/TraitTest.php @@ -0,0 +1,60 @@ +integerProperty = $values['integerProperty']; + $this->stringProperty = $values['stringProperty']; + } +} + +class TraitTest extends TestCase +{ + public function testTraitPropertyAndString(): void + { + $expectedJson = json_encode( + [ + 'integer_property' => 42, + 'string_property' => 'Hello, World!', + ], + JSON_THROW_ON_ERROR + ); + + $object = TypeWithTrait::fromJson($expectedJson); + $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); + $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTestWithTrait.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/UnionArrayTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/UnionArrayTest.php new file mode 100644 index 00000000000..09933d2321d --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/UnionArrayTest.php @@ -0,0 +1,57 @@ + $mixedDates + */ + #[ArrayType(['integer' => new Union('datetime', 'string', 'null')])] + #[JsonProperty('mixed_dates')] + public array $mixedDates; + + /** + * @param array{ + * mixedDates: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->mixedDates = $values['mixedDates']; + } +} + +class UnionArrayTest extends TestCase +{ + public function testUnionArray(): void + { + $expectedJson = json_encode( + [ + 'mixed_dates' => [ + 1 => '2023-01-01T12:00:00+00:00', + 2 => null, + 3 => 'Some String' + ] + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionArray::fromJson($expectedJson); + $this->assertInstanceOf(DateTime::class, $object->mixedDates[1], 'mixed_dates[1] should be a DateTime instance.'); + $this->assertEquals('2023-01-01 12:00:00', $object->mixedDates[1]->format('Y-m-d H:i:s'), 'mixed_dates[1] should have the correct datetime.'); + $this->assertNull($object->mixedDates[2], 'mixed_dates[2] should be null.'); + $this->assertEquals('Some String', $object->mixedDates[3], 'mixed_dates[3] should be "Some String".'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for mixed_dates.'); + } +} diff --git a/seed/php-sdk/inline-types/tests/Seed/Core/Json/UnionPropertyTest.php b/seed/php-sdk/inline-types/tests/Seed/Core/Json/UnionPropertyTest.php new file mode 100644 index 00000000000..3119baace62 --- /dev/null +++ b/seed/php-sdk/inline-types/tests/Seed/Core/Json/UnionPropertyTest.php @@ -0,0 +1,115 @@ + 'integer'], UnionProperty::class)] + #[JsonProperty('complexUnion')] + public mixed $complexUnion; + + /** + * @param array{ + * complexUnion: string|int|null|array|UnionProperty + * } $values + */ + public function __construct( + array $values, + ) { + $this->complexUnion = $values['complexUnion']; + } +} + +class UnionPropertyTest extends TestCase +{ + public function testWithMapOfIntToInt(): void + { + $expectedJson = json_encode( + [ + 'complexUnion' => [1 => 100, 2 => 200] + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsArray($object->complexUnion, 'complexUnion should be an array.'); + $this->assertEquals([1 => 100, 2 => 200], $object->complexUnion, 'complexUnion should match the original map of int => int.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithNestedUnionPropertyType(): void + { + $expectedJson = json_encode( + [ + 'complexUnion' => new UnionProperty( + [ + 'complexUnion' => 'Nested String' + ] + ) + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertInstanceOf(UnionProperty::class, $object->complexUnion, 'complexUnion should be an instance of UnionPropertyType.'); + $this->assertEquals('Nested String', $object->complexUnion->complexUnion, 'Nested complexUnion should match the original value.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithNull(): void + { + $expectedJson = json_encode( + [], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertNull($object->complexUnion, 'complexUnion should be null.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithInteger(): void + { + $expectedJson = json_encode( + [ + 'complexUnion' => 42 + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsInt($object->complexUnion, 'complexUnion should be an integer.'); + $this->assertEquals(42, $object->complexUnion, 'complexUnion should match the original integer.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithString(): void + { + $expectedJson = json_encode( + [ + 'complexUnion' => 'Some String' + ], + JSON_THROW_ON_ERROR + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsString($object->complexUnion, 'complexUnion should be a string.'); + $this->assertEquals('Some String', $object->complexUnion, 'complexUnion should match the original string.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } +} diff --git a/seed/postman/inline-types/.mock/definition/__package__.yml b/seed/postman/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/postman/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/postman/inline-types/.mock/definition/api.yml b/seed/postman/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/postman/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/postman/inline-types/.mock/fern.config.json b/seed/postman/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/postman/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/postman/inline-types/.mock/generators.yml b/seed/postman/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/postman/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/postman/inline-types/collection.json b/seed/postman/inline-types/collection.json new file mode 100644 index 00000000000..a64b1263525 --- /dev/null +++ b/seed/postman/inline-types/collection.json @@ -0,0 +1,97 @@ +{ + "info": { + "name": "Object", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": null + }, + "variable": [ + { + "key": "baseUrl", + "value": "", + "type": "string" + } + ], + "auth": null, + "item": [ + { + "_type": "endpoint", + "name": "Get Root", + "request": { + "description": null, + "url": { + "raw": "{{baseUrl}}/root/root", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "root", + "root" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "type": "text", + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "auth": null, + "body": { + "mode": "raw", + "raw": "{\n \"bar\": {\n \"foo\": \"foo\",\n \"bar\": {\n \"foo\": \"foo\",\n \"bar\": \"bar\",\n \"myEnum\": \"SUNNY\"\n }\n },\n \"foo\": \"foo\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "originalRequest": { + "description": null, + "url": { + "raw": "{{baseUrl}}/root/root", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "root", + "root" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "type": "text", + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "auth": null, + "body": { + "mode": "raw", + "raw": "{\n \"bar\": {\n \"foo\": \"foo\",\n \"bar\": {\n \"foo\": \"foo\",\n \"bar\": \"bar\",\n \"myEnum\": \"SUNNY\"\n }\n },\n \"foo\": \"foo\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "description": null, + "body": "{\n \"foo\": \"foo\",\n \"bar\": {\n \"foo\": \"foo\",\n \"bar\": {\n \"foo\": \"foo\",\n \"bar\": \"bar\",\n \"myEnum\": \"SUNNY\"\n }\n }\n}", + "_postman_previewlanguage": "json" + } + ] + } + ] +} \ No newline at end of file diff --git a/seed/postman/inline-types/snippet-templates.json b/seed/postman/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/postman/inline-types/snippet.json b/seed/postman/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/pydantic/exhaustive/pydantic-v1/.mock/definition/endpoints/content-type.yml b/seed/pydantic/exhaustive/pydantic-v1/.mock/definition/endpoints/content-type.yml new file mode 100644 index 00000000000..7c54e39fa5a --- /dev/null +++ b/seed/pydantic/exhaustive/pydantic-v1/.mock/definition/endpoints/content-type.yml @@ -0,0 +1,19 @@ +imports: + objects: ../types/object.yml + +service: + auth: true + base-path: /foo + endpoints: + postJsonPatchContentType: + path: /bar + method: POST + request: + body: objects.ObjectWithOptionalField + content-type: application/json-patch+json + postJsonPatchContentWithCharsetType: + path: /baz + method: POST + request: + body: objects.ObjectWithOptionalField + content-type: application/json-patch+json; charset=utf-8 diff --git a/seed/pydantic/exhaustive/pydantic-v2/.mock/definition/endpoints/content-type.yml b/seed/pydantic/exhaustive/pydantic-v2/.mock/definition/endpoints/content-type.yml new file mode 100644 index 00000000000..7c54e39fa5a --- /dev/null +++ b/seed/pydantic/exhaustive/pydantic-v2/.mock/definition/endpoints/content-type.yml @@ -0,0 +1,19 @@ +imports: + objects: ../types/object.yml + +service: + auth: true + base-path: /foo + endpoints: + postJsonPatchContentType: + path: /bar + method: POST + request: + body: objects.ObjectWithOptionalField + content-type: application/json-patch+json + postJsonPatchContentWithCharsetType: + path: /baz + method: POST + request: + body: objects.ObjectWithOptionalField + content-type: application/json-patch+json; charset=utf-8 diff --git a/seed/pydantic/inline-types/.github/workflows/ci.yml b/seed/pydantic/inline-types/.github/workflows/ci.yml new file mode 100644 index 00000000000..b204fa604e2 --- /dev/null +++ b/seed/pydantic/inline-types/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: ci + +on: [push] +jobs: + compile: + runs-on: ubuntu-20.04 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + - name: Compile + run: poetry run mypy . + test: + runs-on: ubuntu-20.04 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + + - name: Test + run: poetry run pytest -rP . + + publish: + needs: [compile, test] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-20.04 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Bootstrap poetry + run: | + curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 + - name: Install dependencies + run: poetry install + - name: Publish to pypi + run: | + poetry config repositories.remote + poetry --no-interaction -v publish --build --repository remote --username "$" --password "$" + env: + : ${{ secrets. }} + : ${{ secrets. }} diff --git a/seed/pydantic/inline-types/.gitignore b/seed/pydantic/inline-types/.gitignore new file mode 100644 index 00000000000..0da665feeef --- /dev/null +++ b/seed/pydantic/inline-types/.gitignore @@ -0,0 +1,5 @@ +dist/ +.mypy_cache/ +__pycache__/ +poetry.toml +.ruff_cache/ diff --git a/seed/pydantic/inline-types/.mock/definition/__package__.yml b/seed/pydantic/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/pydantic/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/pydantic/inline-types/.mock/definition/api.yml b/seed/pydantic/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/pydantic/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/pydantic/inline-types/.mock/fern.config.json b/seed/pydantic/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/pydantic/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/pydantic/inline-types/.mock/generators.yml b/seed/pydantic/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/pydantic/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/pydantic/inline-types/README.md b/seed/pydantic/inline-types/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/pydantic/inline-types/pyproject.toml b/seed/pydantic/inline-types/pyproject.toml new file mode 100644 index 00000000000..59c1ffa7302 --- /dev/null +++ b/seed/pydantic/inline-types/pyproject.toml @@ -0,0 +1,59 @@ +[tool.poetry] +name = "fern_inline-types" +version = "0.0.1" +description = "" +readme = "README.md" +authors = [] +keywords = [] + +classifiers = [ + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed" +] +packages = [ + { include = "seed/object", from = "src"} +] + +[project.urls] +Repository = 'https://github.com/inline-types/fern' + +[tool.poetry.dependencies] +python = "^3.8" +pydantic = ">= 1.9.2" +pydantic-core = "^2.18.2" + +[tool.poetry.dev-dependencies] +mypy = "1.0.1" +pytest = "^7.4.0" +pytest-asyncio = "^0.23.5" +python-dateutil = "^2.9.0" +types-python-dateutil = "^2.9.0.20240316" +ruff = "^0.5.6" + +[tool.pytest.ini_options] +testpaths = [ "tests" ] +asyncio_mode = "auto" + +[tool.mypy] +plugins = ["pydantic.mypy"] + +[tool.ruff] +line-length = 120 + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/seed/pydantic/inline-types/snippet-templates.json b/seed/pydantic/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/pydantic/inline-types/snippet.json b/seed/pydantic/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/pydantic/inline-types/src/seed/object/__init__.py b/seed/pydantic/inline-types/src/seed/object/__init__.py new file mode 100644 index 00000000000..5646342ae20 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/__init__.py @@ -0,0 +1,25 @@ +# This file was auto-generated by Fern from our API Definition. + +from .inline_enum import InlineEnum +from .inline_type_1 import InlineType1 +from .inline_type_2 import InlineType2 +from .inlined_discriminated_union_1 import ( + InlinedDiscriminatedUnion1, + InlinedDiscriminatedUnion1_Type1, + InlinedDiscriminatedUnion1_Type2, +) +from .inlined_undiscriminated_union_1 import InlinedUndiscriminatedUnion1 +from .nested_inline_type_1 import NestedInlineType1 +from .root_type_1 import RootType1 + +__all__ = [ + "InlineEnum", + "InlineType1", + "InlineType2", + "InlinedDiscriminatedUnion1", + "InlinedDiscriminatedUnion1_Type1", + "InlinedDiscriminatedUnion1_Type2", + "InlinedUndiscriminatedUnion1", + "NestedInlineType1", + "RootType1", +] diff --git a/seed/pydantic/inline-types/src/seed/object/core/__init__.py b/seed/pydantic/inline-types/src/seed/object/core/__init__.py new file mode 100644 index 00000000000..9c7cd65aa25 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/core/__init__.py @@ -0,0 +1,25 @@ +# This file was auto-generated by Fern from our API Definition. + +from .datetime_utils import serialize_datetime +from .pydantic_utilities import ( + IS_PYDANTIC_V2, + UniversalBaseModel, + UniversalRootModel, + parse_obj_as, + universal_field_validator, + universal_root_validator, + update_forward_refs, +) +from .serialization import FieldMetadata + +__all__ = [ + "FieldMetadata", + "IS_PYDANTIC_V2", + "UniversalBaseModel", + "UniversalRootModel", + "parse_obj_as", + "serialize_datetime", + "universal_field_validator", + "universal_root_validator", + "update_forward_refs", +] diff --git a/seed/pydantic/inline-types/src/seed/object/core/datetime_utils.py b/seed/pydantic/inline-types/src/seed/object/core/datetime_utils.py new file mode 100644 index 00000000000..7c9864a944c --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/core/datetime_utils.py @@ -0,0 +1,28 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt + + +def serialize_datetime(v: dt.datetime) -> str: + """ + Serialize a datetime including timezone info. + + Uses the timezone info provided if present, otherwise uses the current runtime's timezone info. + + UTC datetimes end in "Z" while all other timezones are represented as offset from UTC, e.g. +05:00. + """ + + def _serialize_zoned_datetime(v: dt.datetime) -> str: + if v.tzinfo is not None and v.tzinfo.tzname(None) == dt.timezone.utc.tzname(None): + # UTC is a special case where we use "Z" at the end instead of "+00:00" + return v.isoformat().replace("+00:00", "Z") + else: + # Delegate to the typical +/- offset format + return v.isoformat() + + if v.tzinfo is not None: + return _serialize_zoned_datetime(v) + else: + local_tz = dt.datetime.now().astimezone().tzinfo + localized_dt = v.replace(tzinfo=local_tz) + return _serialize_zoned_datetime(localized_dt) diff --git a/seed/pydantic/inline-types/src/seed/object/core/pydantic_utilities.py b/seed/pydantic/inline-types/src/seed/object/core/pydantic_utilities.py new file mode 100644 index 00000000000..bbe1de41431 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/core/pydantic_utilities.py @@ -0,0 +1,265 @@ +# This file was auto-generated by Fern from our API Definition. + +# nopycln: file +import datetime as dt +import typing +from collections import defaultdict + +import typing_extensions + +import pydantic + +from .datetime_utils import serialize_datetime + +IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + +if IS_PYDANTIC_V2: + # isort will try to reformat the comments on these imports, which breaks mypy + # isort: off + from pydantic.v1.datetime_parse import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + parse_date as parse_date, + ) + from pydantic.v1.datetime_parse import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + parse_datetime as parse_datetime, + ) + from pydantic.v1.json import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + ENCODERS_BY_TYPE as encoders_by_type, + ) + from pydantic.v1.typing import ( # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 + get_args as get_args, + ) + from pydantic.v1.typing import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + get_origin as get_origin, + ) + from pydantic.v1.typing import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + is_literal_type as is_literal_type, + ) + from pydantic.v1.typing import ( # pyright: ignore[reportMissingImports] # Pydantic v2 + is_union as is_union, + ) + from pydantic.v1.fields import ModelField as ModelField # type: ignore # pyright: ignore[reportMissingImports] # Pydantic v2 +else: + from pydantic.datetime_parse import parse_date as parse_date # type: ignore # Pydantic v1 + from pydantic.datetime_parse import parse_datetime as parse_datetime # type: ignore # Pydantic v1 + from pydantic.fields import ModelField as ModelField # type: ignore # Pydantic v1 + from pydantic.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore # Pydantic v1 + from pydantic.typing import get_args as get_args # type: ignore # Pydantic v1 + from pydantic.typing import get_origin as get_origin # type: ignore # Pydantic v1 + from pydantic.typing import is_literal_type as is_literal_type # type: ignore # Pydantic v1 + from pydantic.typing import is_union as is_union # type: ignore # Pydantic v1 + + # isort: on + + +T = typing.TypeVar("T") +Model = typing.TypeVar("Model", bound=pydantic.BaseModel) + + +def parse_obj_as(type_: typing.Type[T], object_: typing.Any) -> T: + if IS_PYDANTIC_V2: + adapter = pydantic.TypeAdapter(type_) # type: ignore # Pydantic v2 + return adapter.validate_python(object_) + else: + return pydantic.parse_obj_as(type_, object_) + + +def to_jsonable_with_fallback( + obj: typing.Any, fallback_serializer: typing.Callable[[typing.Any], typing.Any] +) -> typing.Any: + if IS_PYDANTIC_V2: + from pydantic_core import to_jsonable_python + + return to_jsonable_python(obj, fallback=fallback_serializer) + else: + return fallback_serializer(obj) + + +class UniversalBaseModel(pydantic.BaseModel): + class Config: + populate_by_name = True + smart_union = True + allow_population_by_field_name = True + json_encoders = {dt.datetime: serialize_datetime} + # Allow fields begining with `model_` to be used in the model + protected_namespaces = () + + def json(self, **kwargs: typing.Any) -> str: + kwargs_with_defaults: typing.Any = { + "by_alias": True, + "exclude_unset": True, + **kwargs, + } + if IS_PYDANTIC_V2: + return super().model_dump_json(**kwargs_with_defaults) # type: ignore # Pydantic v2 + else: + return super().json(**kwargs_with_defaults) + + def dict(self, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]: + """ + Override the default dict method to `exclude_unset` by default. This function patches + `exclude_unset` to work include fields within non-None default values. + """ + # Note: the logic here is multi-plexed given the levers exposed in Pydantic V1 vs V2 + # Pydantic V1's .dict can be extremely slow, so we do not want to call it twice. + # + # We'd ideally do the same for Pydantic V2, but it shells out to a library to serialize models + # that we have less control over, and this is less intrusive than custom serializers for now. + if IS_PYDANTIC_V2: + kwargs_with_defaults_exclude_unset: typing.Any = { + **kwargs, + "by_alias": True, + "exclude_unset": True, + "exclude_none": False, + } + kwargs_with_defaults_exclude_none: typing.Any = { + **kwargs, + "by_alias": True, + "exclude_none": True, + "exclude_unset": False, + } + return deep_union_pydantic_dicts( + super().model_dump(**kwargs_with_defaults_exclude_unset), # type: ignore # Pydantic v2 + super().model_dump(**kwargs_with_defaults_exclude_none), # type: ignore # Pydantic v2 + ) + + else: + _fields_set = self.__fields_set__.copy() + + fields = _get_model_fields(self.__class__) + for name, field in fields.items(): + if name not in _fields_set: + default = _get_field_default(field) + + # If the default values are non-null act like they've been set + # This effectively allows exclude_unset to work like exclude_none where + # the latter passes through intentionally set none values. + if default is not None or ("exclude_unset" in kwargs and not kwargs["exclude_unset"]): + _fields_set.add(name) + + if default is not None: + self.__fields_set__.add(name) + + kwargs_with_defaults_exclude_unset_include_fields: typing.Any = { + "by_alias": True, + "exclude_unset": True, + "include": _fields_set, + **kwargs, + } + + return super().dict(**kwargs_with_defaults_exclude_unset_include_fields) + + +def _union_list_of_pydantic_dicts( + source: typing.List[typing.Any], destination: typing.List[typing.Any] +) -> typing.List[typing.Any]: + converted_list: typing.List[typing.Any] = [] + for i, item in enumerate(source): + destination_value = destination[i] # type: ignore + if isinstance(item, dict): + converted_list.append(deep_union_pydantic_dicts(item, destination_value)) + elif isinstance(item, list): + converted_list.append(_union_list_of_pydantic_dicts(item, destination_value)) + else: + converted_list.append(item) + return converted_list + + +def deep_union_pydantic_dicts( + source: typing.Dict[str, typing.Any], destination: typing.Dict[str, typing.Any] +) -> typing.Dict[str, typing.Any]: + for key, value in source.items(): + node = destination.setdefault(key, {}) + if isinstance(value, dict): + deep_union_pydantic_dicts(value, node) + # Note: we do not do this same processing for sets given we do not have sets of models + # and given the sets are unordered, the processing of the set and matching objects would + # be non-trivial. + elif isinstance(value, list): + destination[key] = _union_list_of_pydantic_dicts(value, node) + else: + destination[key] = value + + return destination + + +if IS_PYDANTIC_V2: + + class V2RootModel(UniversalBaseModel, pydantic.RootModel): # type: ignore # Pydantic v2 + pass + + UniversalRootModel: typing_extensions.TypeAlias = V2RootModel # type: ignore +else: + UniversalRootModel: typing_extensions.TypeAlias = UniversalBaseModel # type: ignore + + +def encode_by_type(o: typing.Any) -> typing.Any: + encoders_by_class_tuples: typing.Dict[typing.Callable[[typing.Any], typing.Any], typing.Tuple[typing.Any, ...]] = ( + defaultdict(tuple) + ) + for type_, encoder in encoders_by_type.items(): + encoders_by_class_tuples[encoder] += (type_,) + + if type(o) in encoders_by_type: + return encoders_by_type[type(o)](o) + for encoder, classes_tuple in encoders_by_class_tuples.items(): + if isinstance(o, classes_tuple): + return encoder(o) + + +def update_forward_refs(model: typing.Type["Model"], **localns: typing.Any) -> None: + if IS_PYDANTIC_V2: + model.model_rebuild(raise_errors=False) # type: ignore # Pydantic v2 + else: + model.update_forward_refs(**localns) + + +# Mirrors Pydantic's internal typing +AnyCallable = typing.Callable[..., typing.Any] + + +def universal_root_validator( + pre: bool = False, +) -> typing.Callable[[AnyCallable], AnyCallable]: + def decorator(func: AnyCallable) -> AnyCallable: + if IS_PYDANTIC_V2: + return pydantic.model_validator(mode="before" if pre else "after")(func) # type: ignore # Pydantic v2 + else: + return pydantic.root_validator(pre=pre)(func) # type: ignore # Pydantic v1 + + return decorator + + +def universal_field_validator(field_name: str, pre: bool = False) -> typing.Callable[[AnyCallable], AnyCallable]: + def decorator(func: AnyCallable) -> AnyCallable: + if IS_PYDANTIC_V2: + return pydantic.field_validator(field_name, mode="before" if pre else "after")(func) # type: ignore # Pydantic v2 + else: + return pydantic.validator(field_name, pre=pre)(func) # type: ignore # Pydantic v1 + + return decorator + + +PydanticField = typing.Union[ModelField, pydantic.fields.FieldInfo] + + +def _get_model_fields( + model: typing.Type["Model"], +) -> typing.Mapping[str, PydanticField]: + if IS_PYDANTIC_V2: + return model.model_fields # type: ignore # Pydantic v2 + else: + return model.__fields__ # type: ignore # Pydantic v1 + + +def _get_field_default(field: PydanticField) -> typing.Any: + try: + value = field.get_default() # type: ignore # Pydantic < v1.10.15 + except: + value = field.default + if IS_PYDANTIC_V2: + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + return value diff --git a/seed/pydantic/inline-types/src/seed/object/core/serialization.py b/seed/pydantic/inline-types/src/seed/object/core/serialization.py new file mode 100644 index 00000000000..cb5dcbf93a9 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/core/serialization.py @@ -0,0 +1,272 @@ +# This file was auto-generated by Fern from our API Definition. + +import collections +import inspect +import typing + +import typing_extensions + +import pydantic + + +class FieldMetadata: + """ + Metadata class used to annotate fields to provide additional information. + + Example: + class MyDict(TypedDict): + field: typing.Annotated[str, FieldMetadata(alias="field_name")] + + Will serialize: `{"field": "value"}` + To: `{"field_name": "value"}` + """ + + alias: str + + def __init__(self, *, alias: str) -> None: + self.alias = alias + + +def convert_and_respect_annotation_metadata( + *, + object_: typing.Any, + annotation: typing.Any, + inner_type: typing.Optional[typing.Any] = None, + direction: typing.Literal["read", "write"], +) -> typing.Any: + """ + Respect the metadata annotations on a field, such as aliasing. This function effectively + manipulates the dict-form of an object to respect the metadata annotations. This is primarily used for + TypedDicts, which cannot support aliasing out of the box, and can be extended for additional + utilities, such as defaults. + + Parameters + ---------- + object_ : typing.Any + + annotation : type + The type we're looking to apply typing annotations from + + inner_type : typing.Optional[type] + + Returns + ------- + typing.Any + """ + + if object_ is None: + return None + if inner_type is None: + inner_type = annotation + + clean_type = _remove_annotations(inner_type) + # Pydantic models + if ( + inspect.isclass(clean_type) + and issubclass(clean_type, pydantic.BaseModel) + and isinstance(object_, typing.Mapping) + ): + return _convert_mapping(object_, clean_type, direction) + # TypedDicts + if typing_extensions.is_typeddict(clean_type) and isinstance(object_, typing.Mapping): + return _convert_mapping(object_, clean_type, direction) + + if ( + typing_extensions.get_origin(clean_type) == typing.Dict + or typing_extensions.get_origin(clean_type) == dict + or clean_type == typing.Dict + ) and isinstance(object_, typing.Dict): + key_type = typing_extensions.get_args(clean_type)[0] + value_type = typing_extensions.get_args(clean_type)[1] + + return { + key: convert_and_respect_annotation_metadata( + object_=value, + annotation=annotation, + inner_type=value_type, + direction=direction, + ) + for key, value in object_.items() + } + + # If you're iterating on a string, do not bother to coerce it to a sequence. + if not isinstance(object_, str): + if ( + typing_extensions.get_origin(clean_type) == typing.Set + or typing_extensions.get_origin(clean_type) == set + or clean_type == typing.Set + ) and isinstance(object_, typing.Set): + inner_type = typing_extensions.get_args(clean_type)[0] + return { + convert_and_respect_annotation_metadata( + object_=item, + annotation=annotation, + inner_type=inner_type, + direction=direction, + ) + for item in object_ + } + elif ( + ( + typing_extensions.get_origin(clean_type) == typing.List + or typing_extensions.get_origin(clean_type) == list + or clean_type == typing.List + ) + and isinstance(object_, typing.List) + ) or ( + ( + typing_extensions.get_origin(clean_type) == typing.Sequence + or typing_extensions.get_origin(clean_type) == collections.abc.Sequence + or clean_type == typing.Sequence + ) + and isinstance(object_, typing.Sequence) + ): + inner_type = typing_extensions.get_args(clean_type)[0] + return [ + convert_and_respect_annotation_metadata( + object_=item, + annotation=annotation, + inner_type=inner_type, + direction=direction, + ) + for item in object_ + ] + + if typing_extensions.get_origin(clean_type) == typing.Union: + # We should be able to ~relatively~ safely try to convert keys against all + # member types in the union, the edge case here is if one member aliases a field + # of the same name to a different name from another member + # Or if another member aliases a field of the same name that another member does not. + for member in typing_extensions.get_args(clean_type): + object_ = convert_and_respect_annotation_metadata( + object_=object_, + annotation=annotation, + inner_type=member, + direction=direction, + ) + return object_ + + annotated_type = _get_annotation(annotation) + if annotated_type is None: + return object_ + + # If the object is not a TypedDict, a Union, or other container (list, set, sequence, etc.) + # Then we can safely call it on the recursive conversion. + return object_ + + +def _convert_mapping( + object_: typing.Mapping[str, object], + expected_type: typing.Any, + direction: typing.Literal["read", "write"], +) -> typing.Mapping[str, object]: + converted_object: typing.Dict[str, object] = {} + annotations = typing_extensions.get_type_hints(expected_type, include_extras=True) + aliases_to_field_names = _get_alias_to_field_name(annotations) + for key, value in object_.items(): + if direction == "read" and key in aliases_to_field_names: + dealiased_key = aliases_to_field_names.get(key) + if dealiased_key is not None: + type_ = annotations.get(dealiased_key) + else: + type_ = annotations.get(key) + # Note you can't get the annotation by the field name if you're in read mode, so you must check the aliases map + # + # So this is effectively saying if we're in write mode, and we don't have a type, or if we're in read mode and we don't have an alias + # then we can just pass the value through as is + if type_ is None: + converted_object[key] = value + elif direction == "read" and key not in aliases_to_field_names: + converted_object[key] = convert_and_respect_annotation_metadata( + object_=value, annotation=type_, direction=direction + ) + else: + converted_object[_alias_key(key, type_, direction, aliases_to_field_names)] = ( + convert_and_respect_annotation_metadata(object_=value, annotation=type_, direction=direction) + ) + return converted_object + + +def _get_annotation(type_: typing.Any) -> typing.Optional[typing.Any]: + maybe_annotated_type = typing_extensions.get_origin(type_) + if maybe_annotated_type is None: + return None + + if maybe_annotated_type == typing_extensions.NotRequired: + type_ = typing_extensions.get_args(type_)[0] + maybe_annotated_type = typing_extensions.get_origin(type_) + + if maybe_annotated_type == typing_extensions.Annotated: + return type_ + + return None + + +def _remove_annotations(type_: typing.Any) -> typing.Any: + maybe_annotated_type = typing_extensions.get_origin(type_) + if maybe_annotated_type is None: + return type_ + + if maybe_annotated_type == typing_extensions.NotRequired: + return _remove_annotations(typing_extensions.get_args(type_)[0]) + + if maybe_annotated_type == typing_extensions.Annotated: + return _remove_annotations(typing_extensions.get_args(type_)[0]) + + return type_ + + +def get_alias_to_field_mapping(type_: typing.Any) -> typing.Dict[str, str]: + annotations = typing_extensions.get_type_hints(type_, include_extras=True) + return _get_alias_to_field_name(annotations) + + +def get_field_to_alias_mapping(type_: typing.Any) -> typing.Dict[str, str]: + annotations = typing_extensions.get_type_hints(type_, include_extras=True) + return _get_field_to_alias_name(annotations) + + +def _get_alias_to_field_name( + field_to_hint: typing.Dict[str, typing.Any], +) -> typing.Dict[str, str]: + aliases = {} + for field, hint in field_to_hint.items(): + maybe_alias = _get_alias_from_type(hint) + if maybe_alias is not None: + aliases[maybe_alias] = field + return aliases + + +def _get_field_to_alias_name( + field_to_hint: typing.Dict[str, typing.Any], +) -> typing.Dict[str, str]: + aliases = {} + for field, hint in field_to_hint.items(): + maybe_alias = _get_alias_from_type(hint) + if maybe_alias is not None: + aliases[field] = maybe_alias + return aliases + + +def _get_alias_from_type(type_: typing.Any) -> typing.Optional[str]: + maybe_annotated_type = _get_annotation(type_) + + if maybe_annotated_type is not None: + # The actual annotations are 1 onward, the first is the annotated type + annotations = typing_extensions.get_args(maybe_annotated_type)[1:] + + for annotation in annotations: + if isinstance(annotation, FieldMetadata) and annotation.alias is not None: + return annotation.alias + return None + + +def _alias_key( + key: str, + type_: typing.Any, + direction: typing.Literal["read", "write"], + aliases_to_field_names: typing.Dict[str, str], +) -> str: + if direction == "read": + return aliases_to_field_names.get(key, key) + return _get_alias_from_type(type_=type_) or key diff --git a/seed/pydantic/inline-types/src/seed/object/inline_enum.py b/seed/pydantic/inline-types/src/seed/object/inline_enum.py new file mode 100644 index 00000000000..1692aa9af84 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/inline_enum.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +InlineEnum = typing.Union[typing.Literal["SUNNY", "CLOUDY", "RAINING", "SNOWING"], typing.Any] diff --git a/seed/pydantic/inline-types/src/seed/object/inline_type_1.py b/seed/pydantic/inline-types/src/seed/object/inline_type_1.py new file mode 100644 index 00000000000..c198d47e762 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/inline_type_1.py @@ -0,0 +1,19 @@ +# This file was auto-generated by Fern from our API Definition. + +from .core.pydantic_utilities import UniversalBaseModel +from .nested_inline_type_1 import NestedInlineType1 +from .core.pydantic_utilities import IS_PYDANTIC_V2 +import typing +import pydantic + + +class InlineType1(UniversalBaseModel): + foo: str + bar: NestedInlineType1 + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.allow diff --git a/seed/pydantic/inline-types/src/seed/object/inline_type_2.py b/seed/pydantic/inline-types/src/seed/object/inline_type_2.py new file mode 100644 index 00000000000..0080a451cb0 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/inline_type_2.py @@ -0,0 +1,17 @@ +# This file was auto-generated by Fern from our API Definition. + +from .core.pydantic_utilities import UniversalBaseModel +from .core.pydantic_utilities import IS_PYDANTIC_V2 +import typing +import pydantic + + +class InlineType2(UniversalBaseModel): + baz: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.allow diff --git a/seed/pydantic/inline-types/src/seed/object/inlined_discriminated_union_1.py b/seed/pydantic/inline-types/src/seed/object/inlined_discriminated_union_1.py new file mode 100644 index 00000000000..75e35b89f87 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/inlined_discriminated_union_1.py @@ -0,0 +1,36 @@ +# This file was auto-generated by Fern from our API Definition. + +from __future__ import annotations +from .core.pydantic_utilities import UniversalBaseModel +import typing +from .nested_inline_type_1 import NestedInlineType1 +from .core.pydantic_utilities import IS_PYDANTIC_V2 +import pydantic + + +class InlinedDiscriminatedUnion1_Type1(UniversalBaseModel): + type: typing.Literal["type1"] = "type1" + foo: str + bar: NestedInlineType1 + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.allow + + +class InlinedDiscriminatedUnion1_Type2(UniversalBaseModel): + type: typing.Literal["type2"] = "type2" + baz: str + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.allow + + +InlinedDiscriminatedUnion1 = typing.Union[InlinedDiscriminatedUnion1_Type1, InlinedDiscriminatedUnion1_Type2] diff --git a/seed/pydantic/inline-types/src/seed/object/inlined_undiscriminated_union_1.py b/seed/pydantic/inline-types/src/seed/object/inlined_undiscriminated_union_1.py new file mode 100644 index 00000000000..aaf1f02b5df --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/inlined_undiscriminated_union_1.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from .inline_type_1 import InlineType1 +from .inline_type_2 import InlineType2 + +InlinedUndiscriminatedUnion1 = typing.Union[InlineType1, InlineType2] diff --git a/seed/pydantic/inline-types/src/seed/object/nested_inline_type_1.py b/seed/pydantic/inline-types/src/seed/object/nested_inline_type_1.py new file mode 100644 index 00000000000..dfc89353b41 --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/nested_inline_type_1.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +from .core.pydantic_utilities import UniversalBaseModel +from .inline_enum import InlineEnum +import pydantic +from .core.pydantic_utilities import IS_PYDANTIC_V2 +import typing + + +class NestedInlineType1(UniversalBaseModel): + foo: str + bar: str + my_enum: InlineEnum = pydantic.Field(alias="myEnum") + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.allow diff --git a/seed/pydantic/inline-types/src/seed/object/py.typed b/seed/pydantic/inline-types/src/seed/object/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/pydantic/inline-types/src/seed/object/root_type_1.py b/seed/pydantic/inline-types/src/seed/object/root_type_1.py new file mode 100644 index 00000000000..8e739f0e59b --- /dev/null +++ b/seed/pydantic/inline-types/src/seed/object/root_type_1.py @@ -0,0 +1,19 @@ +# This file was auto-generated by Fern from our API Definition. + +from .core.pydantic_utilities import UniversalBaseModel +from .inline_type_1 import InlineType1 +from .core.pydantic_utilities import IS_PYDANTIC_V2 +import typing +import pydantic + + +class RootType1(UniversalBaseModel): + foo: str + bar: InlineType1 + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + extra = pydantic.Extra.allow diff --git a/seed/pydantic/inline-types/tests/custom/test_client.py b/seed/pydantic/inline-types/tests/custom/test_client.py new file mode 100644 index 00000000000..73f811f5ede --- /dev/null +++ b/seed/pydantic/inline-types/tests/custom/test_client.py @@ -0,0 +1,7 @@ +import pytest + + +# Get started with writing tests with pytest at https://docs.pytest.org +@pytest.mark.skip(reason="Unimplemented") +def test_client() -> None: + assert True == True diff --git a/seed/ruby-model/inline-types/.mock/definition/__package__.yml b/seed/ruby-model/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/ruby-model/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/ruby-model/inline-types/.mock/definition/api.yml b/seed/ruby-model/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/ruby-model/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/ruby-model/inline-types/.mock/fern.config.json b/seed/ruby-model/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/ruby-model/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/ruby-model/inline-types/.mock/generators.yml b/seed/ruby-model/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/ruby-model/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/ruby-model/inline-types/.rubocop.yml b/seed/ruby-model/inline-types/.rubocop.yml new file mode 100644 index 00000000000..c1d2344d6e6 --- /dev/null +++ b/seed/ruby-model/inline-types/.rubocop.yml @@ -0,0 +1,36 @@ +AllCops: + TargetRubyVersion: 2.7 + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Layout/FirstHashElementLineBreak: + Enabled: true + +Layout/MultilineHashKeyLineBreaks: + Enabled: true + +# Generated files may be more complex than standard, disable +# these rules for now as a known limitation. +Metrics/ParameterLists: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false diff --git a/seed/ruby-model/inline-types/Gemfile b/seed/ruby-model/inline-types/Gemfile new file mode 100644 index 00000000000..49bd9cd0173 --- /dev/null +++ b/seed/ruby-model/inline-types/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "minitest", "~> 5.0" +gem "rake", "~> 13.0" +gem "rubocop", "~> 1.21" diff --git a/seed/ruby-model/inline-types/Rakefile b/seed/ruby-model/inline-types/Rakefile new file mode 100644 index 00000000000..2bdbce0cf2c --- /dev/null +++ b/seed/ruby-model/inline-types/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rake/testtask" +require "rubocop/rake_task" + +task default: %i[test rubocop] + +Rake::TestTask.new do |t| + t.pattern = "./test/**/test_*.rb" +end + +RuboCop::RakeTask.new diff --git a/seed/ruby-model/inline-types/lib/gemconfig.rb b/seed/ruby-model/inline-types/lib/gemconfig.rb new file mode 100644 index 00000000000..d90831f0fcd --- /dev/null +++ b/seed/ruby-model/inline-types/lib/gemconfig.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module SeedObjectClient + module Gemconfig + VERSION = "" + AUTHORS = [""].freeze + EMAIL = "" + SUMMARY = "" + DESCRIPTION = "" + HOMEPAGE = "https://github.com/REPO/URL" + SOURCE_CODE_URI = "https://github.com/REPO/URL" + CHANGELOG_URI = "https://github.com/REPO/URL/blob/master/CHANGELOG.md" + end +end diff --git a/seed/ruby-model/inline-types/lib/seed_object_client.rb b/seed/ruby-model/inline-types/lib/seed_object_client.rb new file mode 100644 index 00000000000..8ea9b99068b --- /dev/null +++ b/seed/ruby-model/inline-types/lib/seed_object_client.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "seed_object_client/types/root_type_1" +require_relative "seed_object_client/types/inline_type_1" +require_relative "seed_object_client/types/inline_type_2" +require_relative "seed_object_client/types/nested_inline_type_1" +require_relative "seed_object_client/types/inlined_discriminated_union_1" +require_relative "seed_object_client/types/inlined_undiscriminated_union_1" +require_relative "seed_object_client/types/inline_enum" diff --git a/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_enum.rb b/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_enum.rb new file mode 100644 index 00000000000..c5cc415cac6 --- /dev/null +++ b/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module SeedObjectClient + class InlineEnum + SUNNY = "SUNNY" + CLOUDY = "CLOUDY" + RAINING = "RAINING" + SNOWING = "SNOWING" + end +end diff --git a/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_type_1.rb b/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_type_1.rb new file mode 100644 index 00000000000..b2f58248832 --- /dev/null +++ b/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_type_1.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "nested_inline_type_1" +require "ostruct" +require "json" + +module SeedObjectClient + class InlineType1 + # @return [String] + attr_reader :foo + # @return [SeedObjectClient::NestedInlineType1] + attr_reader :bar + # @return [OpenStruct] Additional properties unmapped to the current class definition + attr_reader :additional_properties + # @return [Object] + attr_reader :_field_set + protected :_field_set + + OMIT = Object.new + + # @param foo [String] + # @param bar [SeedObjectClient::NestedInlineType1] + # @param additional_properties [OpenStruct] Additional properties unmapped to the current class definition + # @return [SeedObjectClient::InlineType1] + def initialize(foo:, bar:, additional_properties: nil) + @foo = foo + @bar = bar + @additional_properties = additional_properties + @_field_set = { "foo": foo, "bar": bar } + end + + # Deserialize a JSON object to an instance of InlineType1 + # + # @param json_object [String] + # @return [SeedObjectClient::InlineType1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + parsed_json = JSON.parse(json_object) + foo = parsed_json["foo"] + if parsed_json["bar"].nil? + bar = nil + else + bar = parsed_json["bar"].to_json + bar = SeedObjectClient::NestedInlineType1.from_json(json_object: bar) + end + new( + foo: foo, + bar: bar, + additional_properties: struct + ) + end + + # Serialize an instance of InlineType1 to a JSON object + # + # @return [String] + def to_json(*_args) + @_field_set&.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + obj.foo.is_a?(String) != false || raise("Passed value for field obj.foo is not the expected type, validation failed.") + SeedObjectClient::NestedInlineType1.validate_raw(obj: obj.bar) + end + end +end diff --git a/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_type_2.rb b/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_type_2.rb new file mode 100644 index 00000000000..232e0e5f47f --- /dev/null +++ b/seed/ruby-model/inline-types/lib/seed_object_client/types/inline_type_2.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "ostruct" +require "json" + +module SeedObjectClient + class InlineType2 + # @return [String] + attr_reader :baz + # @return [OpenStruct] Additional properties unmapped to the current class definition + attr_reader :additional_properties + # @return [Object] + attr_reader :_field_set + protected :_field_set + + OMIT = Object.new + + # @param baz [String] + # @param additional_properties [OpenStruct] Additional properties unmapped to the current class definition + # @return [SeedObjectClient::InlineType2] + def initialize(baz:, additional_properties: nil) + @baz = baz + @additional_properties = additional_properties + @_field_set = { "baz": baz } + end + + # Deserialize a JSON object to an instance of InlineType2 + # + # @param json_object [String] + # @return [SeedObjectClient::InlineType2] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + parsed_json = JSON.parse(json_object) + baz = parsed_json["baz"] + new(baz: baz, additional_properties: struct) + end + + # Serialize an instance of InlineType2 to a JSON object + # + # @return [String] + def to_json(*_args) + @_field_set&.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + obj.baz.is_a?(String) != false || raise("Passed value for field obj.baz is not the expected type, validation failed.") + end + end +end diff --git a/seed/ruby-model/inline-types/lib/seed_object_client/types/inlined_discriminated_union_1.rb b/seed/ruby-model/inline-types/lib/seed_object_client/types/inlined_discriminated_union_1.rb new file mode 100644 index 00000000000..8efbcfeb3bf --- /dev/null +++ b/seed/ruby-model/inline-types/lib/seed_object_client/types/inlined_discriminated_union_1.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "json" +require_relative "inline_type_1" +require_relative "inline_type_2" + +module SeedObjectClient + class InlinedDiscriminatedUnion1 + # @return [Object] + attr_reader :member + # @return [String] + attr_reader :discriminant + + private_class_method :new + alias kind_of? is_a? + + # @param member [Object] + # @param discriminant [String] + # @return [SeedObjectClient::InlinedDiscriminatedUnion1] + def initialize(member:, discriminant:) + @member = member + @discriminant = discriminant + end + + # Deserialize a JSON object to an instance of InlinedDiscriminatedUnion1 + # + # @param json_object [String] + # @return [SeedObjectClient::InlinedDiscriminatedUnion1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + member = case struct.type + when "type1" + SeedObjectClient::InlineType1.from_json(json_object: json_object) + when "type2" + SeedObjectClient::InlineType2.from_json(json_object: json_object) + else + SeedObjectClient::InlineType1.from_json(json_object: json_object) + end + new(member: member, discriminant: struct.type) + end + + # For Union Types, to_json functionality is delegated to the wrapped member. + # + # @return [String] + def to_json(*_args) + case @discriminant + when "type1" + { **@member.to_json, type: @discriminant }.to_json + when "type2" + { **@member.to_json, type: @discriminant }.to_json + else + { "type": @discriminant, value: @member }.to_json + end + @member.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + case obj.type + when "type1" + SeedObjectClient::InlineType1.validate_raw(obj: obj) + when "type2" + SeedObjectClient::InlineType2.validate_raw(obj: obj) + else + raise("Passed value matched no type within the union, validation failed.") + end + end + + # For Union Types, is_a? functionality is delegated to the wrapped member. + # + # @param obj [Object] + # @return [Boolean] + def is_a?(obj) + @member.is_a?(obj) + end + + # @param member [SeedObjectClient::InlineType1] + # @return [SeedObjectClient::InlinedDiscriminatedUnion1] + def self.type_1(member:) + new(member: member, discriminant: "type1") + end + + # @param member [SeedObjectClient::InlineType2] + # @return [SeedObjectClient::InlinedDiscriminatedUnion1] + def self.type_2(member:) + new(member: member, discriminant: "type2") + end + end +end diff --git a/seed/ruby-model/inline-types/lib/seed_object_client/types/inlined_undiscriminated_union_1.rb b/seed/ruby-model/inline-types/lib/seed_object_client/types/inlined_undiscriminated_union_1.rb new file mode 100644 index 00000000000..ec4ef8064b6 --- /dev/null +++ b/seed/ruby-model/inline-types/lib/seed_object_client/types/inlined_undiscriminated_union_1.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "json" +require_relative "inline_type_1" +require_relative "inline_type_2" + +module SeedObjectClient + class InlinedUndiscriminatedUnion1 + # Deserialize a JSON object to an instance of InlinedUndiscriminatedUnion1 + # + # @param json_object [String] + # @return [SeedObjectClient::InlinedUndiscriminatedUnion1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + begin + SeedObjectClient::InlineType1.validate_raw(obj: struct) + return SeedObjectClient::InlineType1.from_json(json_object: struct) unless struct.nil? + + return nil + rescue StandardError + # noop + end + begin + SeedObjectClient::InlineType2.validate_raw(obj: struct) + return SeedObjectClient::InlineType2.from_json(json_object: struct) unless struct.nil? + + return nil + rescue StandardError + # noop + end + struct + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + begin + return SeedObjectClient::InlineType1.validate_raw(obj: obj) + rescue StandardError + # noop + end + begin + return SeedObjectClient::InlineType2.validate_raw(obj: obj) + rescue StandardError + # noop + end + raise("Passed value matched no type within the union, validation failed.") + end + end +end diff --git a/seed/ruby-model/inline-types/lib/seed_object_client/types/nested_inline_type_1.rb b/seed/ruby-model/inline-types/lib/seed_object_client/types/nested_inline_type_1.rb new file mode 100644 index 00000000000..863971a0fa7 --- /dev/null +++ b/seed/ruby-model/inline-types/lib/seed_object_client/types/nested_inline_type_1.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative "inline_enum" +require "ostruct" +require "json" + +module SeedObjectClient + class NestedInlineType1 + # @return [String] + attr_reader :foo + # @return [String] + attr_reader :bar + # @return [SeedObjectClient::InlineEnum] + attr_reader :my_enum + # @return [OpenStruct] Additional properties unmapped to the current class definition + attr_reader :additional_properties + # @return [Object] + attr_reader :_field_set + protected :_field_set + + OMIT = Object.new + + # @param foo [String] + # @param bar [String] + # @param my_enum [SeedObjectClient::InlineEnum] + # @param additional_properties [OpenStruct] Additional properties unmapped to the current class definition + # @return [SeedObjectClient::NestedInlineType1] + def initialize(foo:, bar:, my_enum:, additional_properties: nil) + @foo = foo + @bar = bar + @my_enum = my_enum + @additional_properties = additional_properties + @_field_set = { "foo": foo, "bar": bar, "myEnum": my_enum } + end + + # Deserialize a JSON object to an instance of NestedInlineType1 + # + # @param json_object [String] + # @return [SeedObjectClient::NestedInlineType1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + parsed_json = JSON.parse(json_object) + foo = parsed_json["foo"] + bar = parsed_json["bar"] + my_enum = parsed_json["myEnum"] + new( + foo: foo, + bar: bar, + my_enum: my_enum, + additional_properties: struct + ) + end + + # Serialize an instance of NestedInlineType1 to a JSON object + # + # @return [String] + def to_json(*_args) + @_field_set&.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + obj.foo.is_a?(String) != false || raise("Passed value for field obj.foo is not the expected type, validation failed.") + obj.bar.is_a?(String) != false || raise("Passed value for field obj.bar is not the expected type, validation failed.") + obj.my_enum.is_a?(SeedObjectClient::InlineEnum) != false || raise("Passed value for field obj.my_enum is not the expected type, validation failed.") + end + end +end diff --git a/seed/ruby-model/inline-types/lib/seed_object_client/types/root_type_1.rb b/seed/ruby-model/inline-types/lib/seed_object_client/types/root_type_1.rb new file mode 100644 index 00000000000..a17734f2980 --- /dev/null +++ b/seed/ruby-model/inline-types/lib/seed_object_client/types/root_type_1.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "inline_type_1" +require "ostruct" +require "json" + +module SeedObjectClient + class RootType1 + # @return [String] + attr_reader :foo + # @return [SeedObjectClient::InlineType1] + attr_reader :bar + # @return [OpenStruct] Additional properties unmapped to the current class definition + attr_reader :additional_properties + # @return [Object] + attr_reader :_field_set + protected :_field_set + + OMIT = Object.new + + # @param foo [String] + # @param bar [SeedObjectClient::InlineType1] + # @param additional_properties [OpenStruct] Additional properties unmapped to the current class definition + # @return [SeedObjectClient::RootType1] + def initialize(foo:, bar:, additional_properties: nil) + @foo = foo + @bar = bar + @additional_properties = additional_properties + @_field_set = { "foo": foo, "bar": bar } + end + + # Deserialize a JSON object to an instance of RootType1 + # + # @param json_object [String] + # @return [SeedObjectClient::RootType1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + parsed_json = JSON.parse(json_object) + foo = parsed_json["foo"] + if parsed_json["bar"].nil? + bar = nil + else + bar = parsed_json["bar"].to_json + bar = SeedObjectClient::InlineType1.from_json(json_object: bar) + end + new( + foo: foo, + bar: bar, + additional_properties: struct + ) + end + + # Serialize an instance of RootType1 to a JSON object + # + # @return [String] + def to_json(*_args) + @_field_set&.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + obj.foo.is_a?(String) != false || raise("Passed value for field obj.foo is not the expected type, validation failed.") + SeedObjectClient::InlineType1.validate_raw(obj: obj.bar) + end + end +end diff --git a/seed/ruby-model/inline-types/seed_object_client.gemspec b/seed/ruby-model/inline-types/seed_object_client.gemspec new file mode 100644 index 00000000000..d3b0d2a70a4 --- /dev/null +++ b/seed/ruby-model/inline-types/seed_object_client.gemspec @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "lib/gemconfig" + +Gem::Specification.new do |spec| + spec.name = "seed_object_client" + spec.version = SeedObjectClient::Gemconfig::VERSION + spec.authors = SeedObjectClient::Gemconfig::AUTHORS + spec.email = SeedObjectClient::Gemconfig::EMAIL + spec.summary = SeedObjectClient::Gemconfig::SUMMARY + spec.description = SeedObjectClient::Gemconfig::DESCRIPTION + spec.homepage = SeedObjectClient::Gemconfig::HOMEPAGE + spec.required_ruby_version = ">= 2.7.0" + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = SeedObjectClient::Gemconfig::SOURCE_CODE_URI + spec.metadata["changelog_uri"] = SeedObjectClient::Gemconfig::CHANGELOG_URI + spec.files = Dir.glob("lib/**/*") + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] +end diff --git a/seed/ruby-model/inline-types/snippet-templates.json b/seed/ruby-model/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/ruby-model/inline-types/snippet.json b/seed/ruby-model/inline-types/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/ruby-model/inline-types/test/test_helper.rb b/seed/ruby-model/inline-types/test/test_helper.rb new file mode 100644 index 00000000000..30691efbd9e --- /dev/null +++ b/seed/ruby-model/inline-types/test/test_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "minitest/autorun" +require "seed_object_client" diff --git a/seed/ruby-model/inline-types/test/test_seed_object_client.rb b/seed/ruby-model/inline-types/test/test_seed_object_client.rb new file mode 100644 index 00000000000..d44f9b1e8ec --- /dev/null +++ b/seed/ruby-model/inline-types/test/test_seed_object_client.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "seed_object_client" + +# Basic SeedObjectClient tests +class TestSeedObjectClient < Minitest::Test + def test_function + # SeedObjectClient::Client.new + end +end diff --git a/seed/ruby-sdk/inline-types/.github/workflows/publish.yml b/seed/ruby-sdk/inline-types/.github/workflows/publish.yml new file mode 100644 index 00000000000..ba4ed2d9e2d --- /dev/null +++ b/seed/ruby-sdk/inline-types/.github/workflows/publish.yml @@ -0,0 +1,26 @@ +name: Publish + +on: [push] +jobs: + publish: + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7 + bundler-cache: true + + - name: Test gem + run: bundle install && bundle exec rake test + + - name: Build and Push Gem + env: + GEM_HOST_API_KEY: ${{ secrets. }} + run: | + gem build fern_inline_types.gemspec + + gem push fern_inline_types-*.gem --host diff --git a/seed/ruby-sdk/inline-types/.gitignore b/seed/ruby-sdk/inline-types/.gitignore new file mode 100644 index 00000000000..a97c182a2e1 --- /dev/null +++ b/seed/ruby-sdk/inline-types/.gitignore @@ -0,0 +1,10 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +*.gem +.env diff --git a/seed/ruby-sdk/inline-types/.mock/definition/__package__.yml b/seed/ruby-sdk/inline-types/.mock/definition/__package__.yml new file mode 100644 index 00000000000..a2a0cbfac7c --- /dev/null +++ b/seed/ruby-sdk/inline-types/.mock/definition/__package__.yml @@ -0,0 +1,61 @@ +service: + base-path: /root + auth: false + endpoints: + getRoot: + path: /root + method: POST + request: + body: + properties: + bar: InlineType1 + foo: string + content-type: application/json + name: PostRootRequest + response: RootType1 + +types: + RootType1: + properties: + foo: string + bar: InlineType1 + + InlineType1: + inline: true + properties: + foo: string + bar: + type: NestedInlineType1 + + InlineType2: + inline: true + properties: + baz: string + + NestedInlineType1: + inline: true + properties: + foo: string + bar: string + myEnum: InlineEnum + + InlinedDiscriminatedUnion1: + inline: true + union: + type1: InlineType1 + type2: InlineType2 + + InlinedUndiscriminatedUnion1: + inline: true + discriminated: false + union: + - type: InlineType1 + - type: InlineType2 + + InlineEnum: + inline: true + enum: + - SUNNY + - CLOUDY + - RAINING + - SNOWING diff --git a/seed/ruby-sdk/inline-types/.mock/definition/api.yml b/seed/ruby-sdk/inline-types/.mock/definition/api.yml new file mode 100644 index 00000000000..a82930c145b --- /dev/null +++ b/seed/ruby-sdk/inline-types/.mock/definition/api.yml @@ -0,0 +1 @@ +name: object diff --git a/seed/ruby-sdk/inline-types/.mock/fern.config.json b/seed/ruby-sdk/inline-types/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/ruby-sdk/inline-types/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/ruby-sdk/inline-types/.mock/generators.yml b/seed/ruby-sdk/inline-types/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/ruby-sdk/inline-types/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/ruby-sdk/inline-types/.rubocop.yml b/seed/ruby-sdk/inline-types/.rubocop.yml new file mode 100644 index 00000000000..c1d2344d6e6 --- /dev/null +++ b/seed/ruby-sdk/inline-types/.rubocop.yml @@ -0,0 +1,36 @@ +AllCops: + TargetRubyVersion: 2.7 + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Layout/FirstHashElementLineBreak: + Enabled: true + +Layout/MultilineHashKeyLineBreaks: + Enabled: true + +# Generated files may be more complex than standard, disable +# these rules for now as a known limitation. +Metrics/ParameterLists: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false diff --git a/seed/ruby-sdk/inline-types/Gemfile b/seed/ruby-sdk/inline-types/Gemfile new file mode 100644 index 00000000000..49bd9cd0173 --- /dev/null +++ b/seed/ruby-sdk/inline-types/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "minitest", "~> 5.0" +gem "rake", "~> 13.0" +gem "rubocop", "~> 1.21" diff --git a/seed/ruby-sdk/inline-types/README.md b/seed/ruby-sdk/inline-types/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/ruby-sdk/inline-types/Rakefile b/seed/ruby-sdk/inline-types/Rakefile new file mode 100644 index 00000000000..2bdbce0cf2c --- /dev/null +++ b/seed/ruby-sdk/inline-types/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rake/testtask" +require "rubocop/rake_task" + +task default: %i[test rubocop] + +Rake::TestTask.new do |t| + t.pattern = "./test/**/test_*.rb" +end + +RuboCop::RakeTask.new diff --git a/seed/ruby-sdk/inline-types/fern_inline_types.gemspec b/seed/ruby-sdk/inline-types/fern_inline_types.gemspec new file mode 100644 index 00000000000..c1801fe45db --- /dev/null +++ b/seed/ruby-sdk/inline-types/fern_inline_types.gemspec @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "lib/gemconfig" + +Gem::Specification.new do |spec| + spec.name = "fern_inline_types" + spec.version = "0.0.1" + spec.authors = SeedObjectClient::Gemconfig::AUTHORS + spec.email = SeedObjectClient::Gemconfig::EMAIL + spec.summary = SeedObjectClient::Gemconfig::SUMMARY + spec.description = SeedObjectClient::Gemconfig::DESCRIPTION + spec.homepage = SeedObjectClient::Gemconfig::HOMEPAGE + spec.required_ruby_version = ">= 2.7.0" + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = SeedObjectClient::Gemconfig::SOURCE_CODE_URI + spec.metadata["changelog_uri"] = SeedObjectClient::Gemconfig::CHANGELOG_URI + spec.files = Dir.glob("lib/**/*") + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + spec.add_dependency "async-http-faraday", ">= 0.0", "< 1.0" + spec.add_dependency "faraday", ">= 1.10", "< 3.0" + spec.add_dependency "faraday-net_http", ">= 1.0", "< 4.0" + spec.add_dependency "faraday-retry", ">= 1.0", "< 3.0" +end diff --git a/seed/ruby-sdk/inline-types/lib/fern_inline_types.rb b/seed/ruby-sdk/inline-types/lib/fern_inline_types.rb new file mode 100644 index 00000000000..7e89860cb66 --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/fern_inline_types.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative "types_export" +require_relative "requests" +require_relative "fern_inline_types/types/inline_type_1" +require_relative "fern_inline_types/types/root_type_1" + +module SeedObjectClient + class Client + # @param base_url [String] + # @param max_retries [Long] The number of times to retry a failed request, defaults to 2. + # @param timeout_in_seconds [Long] + # @return [SeedObjectClient::Client] + def initialize(base_url: nil, max_retries: nil, timeout_in_seconds: nil) + @request_client = SeedObjectClient::RequestClient.new( + base_url: base_url, + max_retries: max_retries, + timeout_in_seconds: timeout_in_seconds + ) + end + + # @param bar [Hash] Request of type SeedObjectClient::InlineType1, as a Hash + # * :foo (String) + # * :bar (Hash) + # * :foo (String) + # * :bar (String) + # * :my_enum (SeedObjectClient::InlineEnum) + # @param foo [String] + # @param request_options [SeedObjectClient::RequestOptions] + # @return [SeedObjectClient::RootType1] + # @example + # object = SeedObjectClient::Client.new(base_url: "https://api.example.com") + # object.get_root(bar: { foo: "foo", bar: { foo: "foo", bar: "bar", my_enum: SUNNY } }, foo: "foo") + def get_root(bar:, foo:, request_options: nil) + response = @request_client.conn.post do |req| + req.options.timeout = request_options.timeout_in_seconds unless request_options&.timeout_in_seconds.nil? + req.headers = { + **(req.headers || {}), + **@request_client.get_headers, + **(request_options&.additional_headers || {}) + }.compact + unless request_options.nil? || request_options&.additional_query_parameters.nil? + req.params = { **(request_options&.additional_query_parameters || {}) }.compact + end + req.body = { **(request_options&.additional_body_parameters || {}), bar: bar, foo: foo }.compact + req.url "#{@request_client.get_url(request_options: request_options)}/root/root" + end + SeedObjectClient::RootType1.from_json(json_object: response.body) + end + end + + class AsyncClient + # @param base_url [String] + # @param max_retries [Long] The number of times to retry a failed request, defaults to 2. + # @param timeout_in_seconds [Long] + # @return [SeedObjectClient::AsyncClient] + def initialize(base_url: nil, max_retries: nil, timeout_in_seconds: nil) + @async_request_client = SeedObjectClient::AsyncRequestClient.new( + base_url: base_url, + max_retries: max_retries, + timeout_in_seconds: timeout_in_seconds + ) + end + + # @param bar [Hash] Request of type SeedObjectClient::InlineType1, as a Hash + # * :foo (String) + # * :bar (Hash) + # * :foo (String) + # * :bar (String) + # * :my_enum (SeedObjectClient::InlineEnum) + # @param foo [String] + # @param request_options [SeedObjectClient::RequestOptions] + # @return [SeedObjectClient::RootType1] + # @example + # object = SeedObjectClient::Client.new(base_url: "https://api.example.com") + # object.get_root(bar: { foo: "foo", bar: { foo: "foo", bar: "bar", my_enum: SUNNY } }, foo: "foo") + def get_root(bar:, foo:, request_options: nil) + response = @async_request_client.conn.post do |req| + req.options.timeout = request_options.timeout_in_seconds unless request_options&.timeout_in_seconds.nil? + req.headers = { + **(req.headers || {}), + **@async_request_client.get_headers, + **(request_options&.additional_headers || {}) + }.compact + unless request_options.nil? || request_options&.additional_query_parameters.nil? + req.params = { **(request_options&.additional_query_parameters || {}) }.compact + end + req.body = { **(request_options&.additional_body_parameters || {}), bar: bar, foo: foo }.compact + req.url "#{@async_request_client.get_url(request_options: request_options)}/root/root" + end + SeedObjectClient::RootType1.from_json(json_object: response.body) + end + end +end diff --git a/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_enum.rb b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_enum.rb new file mode 100644 index 00000000000..c5cc415cac6 --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module SeedObjectClient + class InlineEnum + SUNNY = "SUNNY" + CLOUDY = "CLOUDY" + RAINING = "RAINING" + SNOWING = "SNOWING" + end +end diff --git a/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_type_1.rb b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_type_1.rb new file mode 100644 index 00000000000..b2f58248832 --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_type_1.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "nested_inline_type_1" +require "ostruct" +require "json" + +module SeedObjectClient + class InlineType1 + # @return [String] + attr_reader :foo + # @return [SeedObjectClient::NestedInlineType1] + attr_reader :bar + # @return [OpenStruct] Additional properties unmapped to the current class definition + attr_reader :additional_properties + # @return [Object] + attr_reader :_field_set + protected :_field_set + + OMIT = Object.new + + # @param foo [String] + # @param bar [SeedObjectClient::NestedInlineType1] + # @param additional_properties [OpenStruct] Additional properties unmapped to the current class definition + # @return [SeedObjectClient::InlineType1] + def initialize(foo:, bar:, additional_properties: nil) + @foo = foo + @bar = bar + @additional_properties = additional_properties + @_field_set = { "foo": foo, "bar": bar } + end + + # Deserialize a JSON object to an instance of InlineType1 + # + # @param json_object [String] + # @return [SeedObjectClient::InlineType1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + parsed_json = JSON.parse(json_object) + foo = parsed_json["foo"] + if parsed_json["bar"].nil? + bar = nil + else + bar = parsed_json["bar"].to_json + bar = SeedObjectClient::NestedInlineType1.from_json(json_object: bar) + end + new( + foo: foo, + bar: bar, + additional_properties: struct + ) + end + + # Serialize an instance of InlineType1 to a JSON object + # + # @return [String] + def to_json(*_args) + @_field_set&.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + obj.foo.is_a?(String) != false || raise("Passed value for field obj.foo is not the expected type, validation failed.") + SeedObjectClient::NestedInlineType1.validate_raw(obj: obj.bar) + end + end +end diff --git a/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_type_2.rb b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_type_2.rb new file mode 100644 index 00000000000..232e0e5f47f --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inline_type_2.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "ostruct" +require "json" + +module SeedObjectClient + class InlineType2 + # @return [String] + attr_reader :baz + # @return [OpenStruct] Additional properties unmapped to the current class definition + attr_reader :additional_properties + # @return [Object] + attr_reader :_field_set + protected :_field_set + + OMIT = Object.new + + # @param baz [String] + # @param additional_properties [OpenStruct] Additional properties unmapped to the current class definition + # @return [SeedObjectClient::InlineType2] + def initialize(baz:, additional_properties: nil) + @baz = baz + @additional_properties = additional_properties + @_field_set = { "baz": baz } + end + + # Deserialize a JSON object to an instance of InlineType2 + # + # @param json_object [String] + # @return [SeedObjectClient::InlineType2] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + parsed_json = JSON.parse(json_object) + baz = parsed_json["baz"] + new(baz: baz, additional_properties: struct) + end + + # Serialize an instance of InlineType2 to a JSON object + # + # @return [String] + def to_json(*_args) + @_field_set&.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + obj.baz.is_a?(String) != false || raise("Passed value for field obj.baz is not the expected type, validation failed.") + end + end +end diff --git a/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inlined_discriminated_union_1.rb b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inlined_discriminated_union_1.rb new file mode 100644 index 00000000000..8efbcfeb3bf --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inlined_discriminated_union_1.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "json" +require_relative "inline_type_1" +require_relative "inline_type_2" + +module SeedObjectClient + class InlinedDiscriminatedUnion1 + # @return [Object] + attr_reader :member + # @return [String] + attr_reader :discriminant + + private_class_method :new + alias kind_of? is_a? + + # @param member [Object] + # @param discriminant [String] + # @return [SeedObjectClient::InlinedDiscriminatedUnion1] + def initialize(member:, discriminant:) + @member = member + @discriminant = discriminant + end + + # Deserialize a JSON object to an instance of InlinedDiscriminatedUnion1 + # + # @param json_object [String] + # @return [SeedObjectClient::InlinedDiscriminatedUnion1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + member = case struct.type + when "type1" + SeedObjectClient::InlineType1.from_json(json_object: json_object) + when "type2" + SeedObjectClient::InlineType2.from_json(json_object: json_object) + else + SeedObjectClient::InlineType1.from_json(json_object: json_object) + end + new(member: member, discriminant: struct.type) + end + + # For Union Types, to_json functionality is delegated to the wrapped member. + # + # @return [String] + def to_json(*_args) + case @discriminant + when "type1" + { **@member.to_json, type: @discriminant }.to_json + when "type2" + { **@member.to_json, type: @discriminant }.to_json + else + { "type": @discriminant, value: @member }.to_json + end + @member.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + case obj.type + when "type1" + SeedObjectClient::InlineType1.validate_raw(obj: obj) + when "type2" + SeedObjectClient::InlineType2.validate_raw(obj: obj) + else + raise("Passed value matched no type within the union, validation failed.") + end + end + + # For Union Types, is_a? functionality is delegated to the wrapped member. + # + # @param obj [Object] + # @return [Boolean] + def is_a?(obj) + @member.is_a?(obj) + end + + # @param member [SeedObjectClient::InlineType1] + # @return [SeedObjectClient::InlinedDiscriminatedUnion1] + def self.type_1(member:) + new(member: member, discriminant: "type1") + end + + # @param member [SeedObjectClient::InlineType2] + # @return [SeedObjectClient::InlinedDiscriminatedUnion1] + def self.type_2(member:) + new(member: member, discriminant: "type2") + end + end +end diff --git a/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inlined_undiscriminated_union_1.rb b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inlined_undiscriminated_union_1.rb new file mode 100644 index 00000000000..ec4ef8064b6 --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/inlined_undiscriminated_union_1.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "json" +require_relative "inline_type_1" +require_relative "inline_type_2" + +module SeedObjectClient + class InlinedUndiscriminatedUnion1 + # Deserialize a JSON object to an instance of InlinedUndiscriminatedUnion1 + # + # @param json_object [String] + # @return [SeedObjectClient::InlinedUndiscriminatedUnion1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + begin + SeedObjectClient::InlineType1.validate_raw(obj: struct) + return SeedObjectClient::InlineType1.from_json(json_object: struct) unless struct.nil? + + return nil + rescue StandardError + # noop + end + begin + SeedObjectClient::InlineType2.validate_raw(obj: struct) + return SeedObjectClient::InlineType2.from_json(json_object: struct) unless struct.nil? + + return nil + rescue StandardError + # noop + end + struct + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + begin + return SeedObjectClient::InlineType1.validate_raw(obj: obj) + rescue StandardError + # noop + end + begin + return SeedObjectClient::InlineType2.validate_raw(obj: obj) + rescue StandardError + # noop + end + raise("Passed value matched no type within the union, validation failed.") + end + end +end diff --git a/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/nested_inline_type_1.rb b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/nested_inline_type_1.rb new file mode 100644 index 00000000000..863971a0fa7 --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/nested_inline_type_1.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative "inline_enum" +require "ostruct" +require "json" + +module SeedObjectClient + class NestedInlineType1 + # @return [String] + attr_reader :foo + # @return [String] + attr_reader :bar + # @return [SeedObjectClient::InlineEnum] + attr_reader :my_enum + # @return [OpenStruct] Additional properties unmapped to the current class definition + attr_reader :additional_properties + # @return [Object] + attr_reader :_field_set + protected :_field_set + + OMIT = Object.new + + # @param foo [String] + # @param bar [String] + # @param my_enum [SeedObjectClient::InlineEnum] + # @param additional_properties [OpenStruct] Additional properties unmapped to the current class definition + # @return [SeedObjectClient::NestedInlineType1] + def initialize(foo:, bar:, my_enum:, additional_properties: nil) + @foo = foo + @bar = bar + @my_enum = my_enum + @additional_properties = additional_properties + @_field_set = { "foo": foo, "bar": bar, "myEnum": my_enum } + end + + # Deserialize a JSON object to an instance of NestedInlineType1 + # + # @param json_object [String] + # @return [SeedObjectClient::NestedInlineType1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + parsed_json = JSON.parse(json_object) + foo = parsed_json["foo"] + bar = parsed_json["bar"] + my_enum = parsed_json["myEnum"] + new( + foo: foo, + bar: bar, + my_enum: my_enum, + additional_properties: struct + ) + end + + # Serialize an instance of NestedInlineType1 to a JSON object + # + # @return [String] + def to_json(*_args) + @_field_set&.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + obj.foo.is_a?(String) != false || raise("Passed value for field obj.foo is not the expected type, validation failed.") + obj.bar.is_a?(String) != false || raise("Passed value for field obj.bar is not the expected type, validation failed.") + obj.my_enum.is_a?(SeedObjectClient::InlineEnum) != false || raise("Passed value for field obj.my_enum is not the expected type, validation failed.") + end + end +end diff --git a/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/root_type_1.rb b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/root_type_1.rb new file mode 100644 index 00000000000..a17734f2980 --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/fern_inline_types/types/root_type_1.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "inline_type_1" +require "ostruct" +require "json" + +module SeedObjectClient + class RootType1 + # @return [String] + attr_reader :foo + # @return [SeedObjectClient::InlineType1] + attr_reader :bar + # @return [OpenStruct] Additional properties unmapped to the current class definition + attr_reader :additional_properties + # @return [Object] + attr_reader :_field_set + protected :_field_set + + OMIT = Object.new + + # @param foo [String] + # @param bar [SeedObjectClient::InlineType1] + # @param additional_properties [OpenStruct] Additional properties unmapped to the current class definition + # @return [SeedObjectClient::RootType1] + def initialize(foo:, bar:, additional_properties: nil) + @foo = foo + @bar = bar + @additional_properties = additional_properties + @_field_set = { "foo": foo, "bar": bar } + end + + # Deserialize a JSON object to an instance of RootType1 + # + # @param json_object [String] + # @return [SeedObjectClient::RootType1] + def self.from_json(json_object:) + struct = JSON.parse(json_object, object_class: OpenStruct) + parsed_json = JSON.parse(json_object) + foo = parsed_json["foo"] + if parsed_json["bar"].nil? + bar = nil + else + bar = parsed_json["bar"].to_json + bar = SeedObjectClient::InlineType1.from_json(json_object: bar) + end + new( + foo: foo, + bar: bar, + additional_properties: struct + ) + end + + # Serialize an instance of RootType1 to a JSON object + # + # @return [String] + def to_json(*_args) + @_field_set&.to_json + end + + # Leveraged for Union-type generation, validate_raw attempts to parse the given + # hash and check each fields type against the current object's property + # definitions. + # + # @param obj [Object] + # @return [Void] + def self.validate_raw(obj:) + obj.foo.is_a?(String) != false || raise("Passed value for field obj.foo is not the expected type, validation failed.") + SeedObjectClient::InlineType1.validate_raw(obj: obj.bar) + end + end +end diff --git a/seed/ruby-sdk/inline-types/lib/gemconfig.rb b/seed/ruby-sdk/inline-types/lib/gemconfig.rb new file mode 100644 index 00000000000..87100746b48 --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/gemconfig.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module SeedObjectClient + module Gemconfig + VERSION = "" + AUTHORS = [""].freeze + EMAIL = "" + SUMMARY = "" + DESCRIPTION = "" + HOMEPAGE = "https://github.com/inline-types/fern" + SOURCE_CODE_URI = "https://github.com/inline-types/fern" + CHANGELOG_URI = "https://github.com/inline-types/fern/blob/master/CHANGELOG.md" + end +end diff --git a/seed/ruby-sdk/inline-types/lib/requests.rb b/seed/ruby-sdk/inline-types/lib/requests.rb new file mode 100644 index 00000000000..3510d05bf7b --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/requests.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "faraday" +require "faraday/retry" +require "async/http/faraday" + +module SeedObjectClient + class RequestClient + # @return [Faraday] + attr_reader :conn + # @return [String] + attr_reader :base_url + + # @param base_url [String] + # @param max_retries [Long] The number of times to retry a failed request, defaults to 2. + # @param timeout_in_seconds [Long] + # @return [SeedObjectClient::RequestClient] + def initialize(base_url: nil, max_retries: nil, timeout_in_seconds: nil) + @base_url = base_url + @conn = Faraday.new do |faraday| + faraday.request :json + faraday.response :raise_error, include_request: true + faraday.request :retry, { max: max_retries } unless max_retries.nil? + faraday.options.timeout = timeout_in_seconds unless timeout_in_seconds.nil? + end + end + + # @param request_options [SeedObjectClient::RequestOptions] + # @return [String] + def get_url(request_options: nil) + request_options&.base_url || @base_url + end + + # @return [Hash{String => String}] + def get_headers + { "X-Fern-Language": "Ruby", "X-Fern-SDK-Name": "fern_inline_types", "X-Fern-SDK-Version": "0.0.1" } + end + end + + class AsyncRequestClient + # @return [Faraday] + attr_reader :conn + # @return [String] + attr_reader :base_url + + # @param base_url [String] + # @param max_retries [Long] The number of times to retry a failed request, defaults to 2. + # @param timeout_in_seconds [Long] + # @return [SeedObjectClient::AsyncRequestClient] + def initialize(base_url: nil, max_retries: nil, timeout_in_seconds: nil) + @base_url = base_url + @conn = Faraday.new do |faraday| + faraday.request :json + faraday.response :raise_error, include_request: true + faraday.adapter :async_http + faraday.request :retry, { max: max_retries } unless max_retries.nil? + faraday.options.timeout = timeout_in_seconds unless timeout_in_seconds.nil? + end + end + + # @param request_options [SeedObjectClient::RequestOptions] + # @return [String] + def get_url(request_options: nil) + request_options&.base_url || @base_url + end + + # @return [Hash{String => String}] + def get_headers + { "X-Fern-Language": "Ruby", "X-Fern-SDK-Name": "fern_inline_types", "X-Fern-SDK-Version": "0.0.1" } + end + end + + # Additional options for request-specific configuration when calling APIs via the + # SDK. + class RequestOptions + # @return [String] + attr_reader :base_url + # @return [Hash{String => Object}] + attr_reader :additional_headers + # @return [Hash{String => Object}] + attr_reader :additional_query_parameters + # @return [Hash{String => Object}] + attr_reader :additional_body_parameters + # @return [Long] + attr_reader :timeout_in_seconds + + # @param base_url [String] + # @param additional_headers [Hash{String => Object}] + # @param additional_query_parameters [Hash{String => Object}] + # @param additional_body_parameters [Hash{String => Object}] + # @param timeout_in_seconds [Long] + # @return [SeedObjectClient::RequestOptions] + def initialize(base_url: nil, additional_headers: nil, additional_query_parameters: nil, + additional_body_parameters: nil, timeout_in_seconds: nil) + @base_url = base_url + @additional_headers = additional_headers + @additional_query_parameters = additional_query_parameters + @additional_body_parameters = additional_body_parameters + @timeout_in_seconds = timeout_in_seconds + end + end + + # Additional options for request-specific configuration when calling APIs via the + # SDK. + class IdempotencyRequestOptions + # @return [String] + attr_reader :base_url + # @return [Hash{String => Object}] + attr_reader :additional_headers + # @return [Hash{String => Object}] + attr_reader :additional_query_parameters + # @return [Hash{String => Object}] + attr_reader :additional_body_parameters + # @return [Long] + attr_reader :timeout_in_seconds + + # @param base_url [String] + # @param additional_headers [Hash{String => Object}] + # @param additional_query_parameters [Hash{String => Object}] + # @param additional_body_parameters [Hash{String => Object}] + # @param timeout_in_seconds [Long] + # @return [SeedObjectClient::IdempotencyRequestOptions] + def initialize(base_url: nil, additional_headers: nil, additional_query_parameters: nil, + additional_body_parameters: nil, timeout_in_seconds: nil) + @base_url = base_url + @additional_headers = additional_headers + @additional_query_parameters = additional_query_parameters + @additional_body_parameters = additional_body_parameters + @timeout_in_seconds = timeout_in_seconds + end + end +end diff --git a/seed/ruby-sdk/inline-types/lib/types_export.rb b/seed/ruby-sdk/inline-types/lib/types_export.rb new file mode 100644 index 00000000000..adae273e434 --- /dev/null +++ b/seed/ruby-sdk/inline-types/lib/types_export.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "fern_inline_types/types/root_type_1" +require_relative "fern_inline_types/types/inline_type_1" +require_relative "fern_inline_types/types/inline_type_2" +require_relative "fern_inline_types/types/nested_inline_type_1" +require_relative "fern_inline_types/types/inlined_discriminated_union_1" +require_relative "fern_inline_types/types/inlined_undiscriminated_union_1" +require_relative "fern_inline_types/types/inline_enum" diff --git a/seed/ruby-sdk/inline-types/snippet-templates.json b/seed/ruby-sdk/inline-types/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/ruby-sdk/inline-types/snippet.json b/seed/ruby-sdk/inline-types/snippet.json new file mode 100644 index 00000000000..ef341eb4337 --- /dev/null +++ b/seed/ruby-sdk/inline-types/snippet.json @@ -0,0 +1,27 @@ +{ + "endpoints": [ + { + "id": { + "path": "/root/root", + "method": "POST", + "identifierOverride": "endpoint_.getRoot" + }, + "snippet": { + "client": "require \"fern_inline_types\"\n\nobject = SeedObjectClient::Client.new(base_url: \"https://api.example.com\")\nobject.get_root(bar: { foo: \"foo\", bar: { foo: \"foo\", bar: \"bar\", my_enum: SUNNY } }, foo: \"foo\")", + "type": "ruby" + } + }, + { + "id": { + "path": "/root/root", + "method": "POST", + "identifierOverride": "endpoint_.getRoot" + }, + "snippet": { + "client": "require \"fern_inline_types\"\n\nobject = SeedObjectClient::Client.new(base_url: \"https://api.example.com\")\nobject.get_root(bar: { foo: \"foo\", bar: { foo: \"foo\", bar: \"bar\", my_enum: SUNNY } }, foo: \"foo\")", + "type": "ruby" + } + } + ], + "types": {} +} \ No newline at end of file diff --git a/seed/ruby-sdk/inline-types/test/test_fern_inline_types.rb b/seed/ruby-sdk/inline-types/test/test_fern_inline_types.rb new file mode 100644 index 00000000000..c4ac74a66e3 --- /dev/null +++ b/seed/ruby-sdk/inline-types/test/test_fern_inline_types.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "fern_inline_types" + +# Basic SeedObjectClient tests +class TestSeedObjectClient < Minitest::Test + def test_function + # SeedObjectClient::Client.new + end +end diff --git a/seed/ruby-sdk/inline-types/test/test_helper.rb b/seed/ruby-sdk/inline-types/test/test_helper.rb new file mode 100644 index 00000000000..c49fbd539bd --- /dev/null +++ b/seed/ruby-sdk/inline-types/test/test_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "minitest/autorun" +require "fern_inline_types"