Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(csharp): add pagination helper methods to C# SDK generator #5187

Merged
merged 22 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f3c4651
WIP: Generate pager endpoints
Swimburger Nov 15, 2024
80b675a
Merge branch 'main' of https://github.com/fern-api/fern into niels/cs…
Swimburger Nov 15, 2024
10ce7c3
feat(csharp): Refactor Pager (WIP)
Swimburger Nov 15, 2024
e4721bd
Fix HttpPagerEndpointGenerator namespace name
Swimburger Nov 18, 2024
1699ca3
Refactor C# pager generation to have a single public method
Swimburger Nov 18, 2024
832c1f1
Remove pagination if no endpoints have pagination
Swimburger Nov 18, 2024
28ae905
C# mockserver test pagination
Swimburger Nov 18, 2024
3dedd98
Merge branch 'main' into niels/csharp/pagination
Swimburger Nov 18, 2024
3f976a6
format
Swimburger Nov 18, 2024
bfd7f43
Fix `pnpm format`
Swimburger Nov 18, 2024
88b265f
Improve pagination
Swimburger Nov 18, 2024
f09d4f2
Incorporate feedback + revert pagination seed test definition
Swimburger Nov 19, 2024
59dbb9b
Undo cached seed definition change
Swimburger Nov 19, 2024
baee9f0
clone request before paginating
Swimburger Nov 19, 2024
75e1c63
fix eslint issue
Swimburger Nov 19, 2024
4adbe53
Add version to C# SDK versions.yml
Swimburger Nov 19, 2024
550cfd4
chore: update changelog
fern-bot Nov 19, 2024
017c1ea
Apply suggestions from code review
Swimburger Nov 19, 2024
ec17568
Undo .vscode settings.json change
Swimburger Nov 19, 2024
3b8e766
Merge branch 'niels/csharp/pagination' of https://github.com/fern-api…
Swimburger Nov 19, 2024
b680a29
Add test cases for pagination
Swimburger Nov 19, 2024
e5d5bde
Fix writing test files to correct location
Swimburger Nov 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,24 @@
"**/.yarn": true,
},
"eslint.nodePath": ".yarn/sdks",
"eslint.execArgv": ["--max_old_space_size=16384"],
"eslint.workingDirectories": [ "./packages", "./generators" ],
"eslint.execArgv": [
"--max_old_space_size=16384"
],
"eslint.workingDirectories": [
"./packages",
"./generators"
],
"prettier.prettierPath": "node_modules/prettier/index.js",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": ["source.fixAll", "source.sortMembers"],
"editor.formatOnSave": false,
Swimburger marked this conversation as resolved.
Show resolved Hide resolved
"editor.codeActionsOnSave": [
"source.fixAll",
"source.sortMembers"
],
"editor.defaultFormatter": null,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"python.analysis.typeCheckingMode": "basic"
}
}
18 changes: 10 additions & 8 deletions generators/csharp/codegen/src/AsIs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,34 @@ export const CONSTANTS_CLASS_NAME = "Constants";
export const JSON_UTILS_CLASS_NAME = "JsonUtils";

export enum AsIsFiles {
GitIgnore = ".gitignore.Template",
CiYaml = "github-ci.yml",
CollectionItemSerializer = "CollectionItemSerializer.cs",
Constants = "Constants.cs",
CustomProps = "Custom.props.Template",
DateTimeSerializer = "DateTimeSerializer.cs",
EnumConverter = "EnumConverter.Template.cs",
EnumSerializer = "EnumSerializer.Template.cs",
EnumSerializerTests = "EnumSerializerTests.Template.cs",
Extensions = "Extensions.cs",
GitIgnore = ".gitignore.Template",
GrpcRequestOptions = "GrpcRequestOptions.Template.cs",
Headers = "Headers.Template.cs",
HeaderValue = "HeaderValue.Template.cs",
HttpMethodExtensions = "HttpMethodExtensions.cs",
JsonConfiguration = "JsonConfiguration.cs",
OneOfSerializer = "OneOfSerializer.cs",
Page = "Page.Template.cs",
Pager = "Pager.Template.cs",
RawClient = "RawClient.Template.cs",
RawClientTests = "RawClientTests.Template.cs",
RawGrpcClient = "RawGrpcClient.Template.cs",
EnumSerializer = "EnumSerializer.Template.cs",
EnumSerializerTests = "EnumSerializerTests.Template.cs",
StringEnum = "StringEnum.Template.cs",
StringEnumExtensions = "StringEnumExtensions.Template.cs",
StringEnumSerializer = "StringEnumSerializer.Template.cs",
StringEnumSerializerTests = "StringEnumSerializerTests.Template.cs",
TemplateCsProj = "Template.csproj",
TemplateTestCsProj = "Template.Test.csproj",
TemplateTestClientCs = "TemplateTestClient.cs",
UsingCs = "Using.cs",
Extensions = "Extensions.cs",
CustomProps = "Custom.props.Template",
TestCustomProps = "Test.Custom.props.Template"
TemplateTestCsProj = "Template.Test.csproj",
TestCustomProps = "Test.Custom.props.Template",
UsingCs = "Using.cs"
}
19 changes: 19 additions & 0 deletions generators/csharp/codegen/src/asIs/Page.Template.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace <%= namespace%>;

/// <summary>
/// A single <see cref="Page{TItem}"/> of items from a request that may return
/// zero or more <see cref="Page{TItem}"/>s of items.
/// </summary>
/// <typeparam name="TItem">The type of items.</typeparam>
public class Page<TItem>
{
public Page(IReadOnlyList<TItem> items)
{
Items = items;
}

/// <summary>
/// Gets the items in this <see cref="Page{TItem}"/>.
/// </summary>
public IReadOnlyList<TItem> Items { get; }
}
211 changes: 211 additions & 0 deletions generators/csharp/codegen/src/asIs/Pager.Template.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
using System.Runtime.CompilerServices;

namespace <%= namespace%>;

/// <summary>
/// A collection of values that may take multiple service requests to
/// iterate over.
/// </summary>
/// <typeparam name="TItem">The type of the values.</typeparam>
public abstract class Pager<TItem> : IAsyncEnumerable<TItem>
{
/// <summary>
/// Enumerate the values a <see cref="Page{TItem}"/> at a time. This may
/// make multiple service requests.
/// </summary>
/// <returns>
/// An async sequence of <see cref="Page{TItem}"/>s.
/// </returns>
public abstract IAsyncEnumerable<Page<TItem>> AsPagesAsync(
CancellationToken cancellationToken = default
);

/// <summary>
/// Enumerate the values in the collection asynchronously. This may
/// make multiple service requests.
/// </summary>
/// <param name="cancellationToken">
/// The <see cref="CancellationToken"/> used for requests made while
/// enumerating asynchronously.
/// </param>
/// <returns>An async sequence of values.</returns>
public virtual async IAsyncEnumerator<TItem> GetAsyncEnumerator(
CancellationToken cancellationToken = default
)
{
await foreach (var page in AsPagesAsync(cancellationToken).ConfigureAwait(false))
{
foreach (var value in page.Items)
{
yield return value;
}
}
}
}

internal sealed class OffsetPager<TRequest, TRequestOptions, TResponse, TOffset, TStep, TItem>
: Pager<TItem>
{
private TRequest _request;
private readonly TRequestOptions? _options;
private readonly GetNextPage _getNextPage;
private readonly GetOffset _getOffset;
private readonly SetOffset _setOffset;
private readonly GetStep? _getStep;
private readonly GetItems _getItems;
private readonly HasNextPage? _hasNextPage;

internal delegate Task<TResponse> GetNextPage(
TRequest request,
TRequestOptions? options,
CancellationToken cancellationToken
);

internal delegate TOffset GetOffset(TRequest request);

internal delegate void SetOffset(TRequest request, TOffset offset);

internal delegate TStep GetStep(TRequest request);

internal delegate IReadOnlyList<TItem>? GetItems(TResponse response);

internal delegate bool? HasNextPage(TResponse response);

internal OffsetPager(
TRequest request,
TRequestOptions? options,
GetNextPage getNextPage,
GetOffset getOffset,
SetOffset setOffset,
GetStep? getStep,
GetItems getItems,
HasNextPage? hasNextPage
)
{
_request = request;
_options = options;
_getNextPage = getNextPage;
_getOffset = getOffset;
_setOffset = setOffset;
_getStep = getStep;
_getItems = getItems;
_hasNextPage = hasNextPage;
}

public override async IAsyncEnumerable<Page<TItem>> AsPagesAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
var hasStep = false;
if(_getStep is not null)
{
hasStep = _getStep(_request) is not null;
}
var offset = _getOffset(_request);
var longOffset = Convert.ToInt64(offset);
bool hasNextPage;
do
{
var response = await _getNextPage(_request, _options, cancellationToken)
.ConfigureAwait(false);
var items = _getItems(response);
var itemCount = items?.Count ?? 0;
hasNextPage = _hasNextPage?.Invoke(response) ?? itemCount > 0;
if (items is not null)
{
yield return new Page<TItem>(items);
}

// If there is a step, we need to increment the offset by the number of items
if (hasStep)
{
longOffset += items?.Count ?? 1;
}
else
{
longOffset++;
}

// ensure there's a request object to set the offset on
_request ??= Activator.CreateInstance<TRequest>();
switch (offset)
{
case int:
// safely cast long to int
_setOffset(_request, (TOffset)(object)(int)longOffset);
break;
case long:
_setOffset(_request, (TOffset)(object)longOffset);
break;
default:
throw new InvalidOperationException("Offset must be int or long");
Swimburger marked this conversation as resolved.
Show resolved Hide resolved
}
} while (hasNextPage);
}
}

internal sealed class CursorPager<TRequest, TRequestOptions, TResponse, TCursor, TItem>
: Pager<TItem>
{
private TRequest _request;
private readonly TRequestOptions? _options;
private readonly GetNextPage _getNextPage;
private readonly SetCursor _setCursor;
private readonly GetNextCursor _getNextCursor;
private readonly GetItems _getItems;

internal delegate Task<TResponse> GetNextPage(
TRequest request,
TRequestOptions? options,
CancellationToken cancellationToken
);

internal delegate void SetCursor(TRequest request, TCursor cursor);

internal delegate TCursor? GetNextCursor(TResponse response);

internal delegate IReadOnlyList<TItem>? GetItems(TResponse response);

internal CursorPager(
TRequest request,
TRequestOptions? options,
GetNextPage getNextPage,
SetCursor setCursor,
GetNextCursor getNextCursor,
GetItems getItems
)
{
_request = request;
_options = options;
_getNextPage = getNextPage;
_setCursor = setCursor;
_getNextCursor = getNextCursor;
_getItems = getItems;
}

public override async IAsyncEnumerable<Page<TItem>> AsPagesAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
do
{
var response = await _getNextPage(_request, _options, cancellationToken)
.ConfigureAwait(false);
var items = _getItems(response);
var nextCursor = _getNextCursor(response);
if (items != null)
{
yield return new Page<TItem>(items);
}

if (nextCursor == null)
{
break;
}

// ensure there's a request object to set the cursor on
_request ??= Activator.CreateInstance<TRequest>();
_setCursor(_request, nextCursor);
} while (true);
}
}
4 changes: 4 additions & 0 deletions generators/csharp/codegen/src/ast/Class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ export class Class extends AstNode {
this.methods.push(method);
}

public addMethods(methods: Method[]): void {
methods.forEach((method) => this.addMethod(method));
}

public addNestedClass(subClass: Class): void {
this.nestedClasses.push(subClass);
}
Expand Down
58 changes: 58 additions & 0 deletions generators/csharp/sdk/src/SdkGeneratorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@
if (this.hasGrpcEndpoints()) {
files.push(AsIsFiles.RawGrpcClient);
}
if (this.hasPagination()) {
files.push(AsIsFiles.Page);
files.push(AsIsFiles.Pager);
}
if (this.customConfig["experimental-enable-forward-compatible-enums"] ?? false) {
files.push(AsIsFiles.StringEnum);
files.push(AsIsFiles.StringEnumExtensions);
Expand All @@ -158,6 +162,10 @@
return files;
}

public hasPagination() {

Check failure on line 165 in generators/csharp/sdk/src/SdkGeneratorContext.ts

View workflow job for this annotation

GitHub Actions / eslint

Missing return type on function
return this.config.generatePaginatedClients && this.ir.sdkConfig.hasPaginatedEndpoints;
}

public getCoreTestAsIsFiles(): string[] {
const files = [AsIsFiles.RawClientTests];
if (this.customConfig["experimental-enable-forward-compatible-enums"] ?? false) {
Expand Down Expand Up @@ -492,6 +500,56 @@
return undefined;
}

public getPagerClassReference({ itemType }: { itemType: csharp.Type }): csharp.ClassReference {
return csharp.classReference({
namespace: this.getCoreNamespace(),
name: "Pager",
generics: [itemType]
});
}

public getOffsetPagerClassReference({
requestType,
requestOptionsType,
responseType,
offsetType,
stepType,
itemType
}: {
requestType: csharp.Type;
requestOptionsType: csharp.Type;
responseType: csharp.Type;
offsetType: csharp.Type;
stepType: csharp.Type;
itemType: csharp.Type;
}): csharp.ClassReference {
return csharp.classReference({
namespace: this.getCoreNamespace(),
name: "OffsetPager",
generics: [requestType, requestOptionsType, responseType, offsetType, stepType, itemType]
});
}

public getCursorPagerClassReference({
requestType,
requestOptionsType,
responseType,
cursorType,
itemType
}: {
requestType: csharp.Type;
requestOptionsType: csharp.Type;
responseType: csharp.Type;
cursorType: csharp.Type;
itemType: csharp.Type;
}): csharp.ClassReference {
return csharp.classReference({
namespace: this.getCoreNamespace(),
name: "CursorPager",
generics: [requestType, requestOptionsType, responseType, cursorType, itemType]
});
}

public resolveEndpointOrThrow(service: HttpService, endpointId: EndpointId): HttpEndpoint {
const httpEndpoint = service.endpoints.find((endpoint) => endpoint.id === endpointId);
if (httpEndpoint == null) {
Expand Down
Loading
Loading