From 3b84015fee719451af131753f3e198a6d00d648a Mon Sep 17 00:00:00 2001 From: Sarmad Wahab Date: Wed, 2 Sep 2026 23:54:49 -0500 Subject: [PATCH 1/3] fix: clear every build warning and move generation fixups into templates The CI build emitted 674 warnings. All of them are now fixed at their source, and the post-generation sed passes that caused several of them are gone. Warnings CS8073 (416) Equals/GetHashCode gated their null checks on vendorExtensions.x-is-value-type, which openapi-generator computes from a hardcoded list that omits DateTime and DateOnly, so required date properties got a null check the compiler proved dead. Switched to x-csharp-value-type, which is derived from the emitted C# type and is already used by the constructor block in the same template. CS0436 (4) generate.sh copied wrapper/VRChat.API/Client/* into src/VRChat.API/Client/, so VRChat.API.Wrapper compiled those files and also referenced VRChat.API, which already contained them. The wrapper is now compiled straight from wrapper/ by the generated csproj, so each type has a single definition and VRChat.API.Wrapper.csproj is deleted. CS1591 (60) / CS1572 (2) Documented VRChatClient, whose IVRChat members take from the already-documented interface, plus IVRChatClientFactory.TryAddClient, and dropped a tag on WithCredentials, which has no such parameter. generate.sh: 92 lines to 59, 19 seds to 2, 9 rm lines to 0 .openapi-generator-ignore now covers docs/, api/, src/VRChat.API.Test/, appveyor.yml and git_push.sh, so they are never written rather than deleted afterwards. rm build.sh, build.bat, mono_nunit_test.sh and nuget.exe were dead: generator 7.17.0 does not emit them. netcore_project.additions.mustache, the generator's own csproj extension point, now carries the wrapper sources, the Otp.NET reference, the packaged assets and the property overrides. It renders last, so its PropertyGroup wins and netcore_project.mustache stays stock. Package metadata moved to --additional-properties. This also fixes the copyright: its (c) sign was UTF-8 encoded twice by the sed, so the package shipped the bytes c3 82 c2 a9 where c2 a9 was meant. Passing the string once through a generator property encodes it correctly. packageTags and releaseNote cannot go there, because the CLI splits that flag on commas, so they are set in the additions file. Cookie path/domain and credential URL-encoding moved into the httpclient api.mustache. A third cookie sed and a README injection sed were dead, both keyed on strings absent from the output. ITwoFactorCode and CurrentUser.RequiresTwoFactorAuth are now partial classes in wrapper/VRChat.API/Model/. The latter is a spec gap: the specification models requiresTwoFactorAuth on TwoFactorRecoveryCodes, not CurrentUser. One patch to generated output survives, the IsRequired relaxation on CurrentUser, which no template or partial class can express. It now runs through a helper that probes with grep first, since sed exits 0 when it matches nothing and set -e would never catch a patch that stopped applying. Also DefaultVRChatClientFactory.TryAddClient called Dictionary.Add, which throws when the key exists, so overrideIfExists: true never overrode. Uses the indexer now. The publish job overlays the artifact onto a checkout, so files the generator stops emitting survived forever. It now clears src/ first. This removes the orphaned Model/GroupRoleTemplateValuesRoles.cs. Verified by regenerating against spec v1.20.9-nightly.24 with generator 7.17.0 and building the solution: 0 warnings, 0 errors, all six assemblies. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CpDekr34WgDkSS5myqf8m9 --- .github/workflows/ci.yaml | 4 + .openapi-generator-ignore | 12 +- VRChat.API.sln | 6 - generate.sh | 105 ++++++------------ templates/libraries/httpclient/api.mustache | 8 +- templates/modelGeneric.mustache | 16 +-- templates/netcore_project.additions.mustache | 40 ++++++- templates/partial_header.mustache | 4 - .../DefaultVRChatClientFactory.cs | 3 +- .../IVRChatClientFactory.cs | 10 ++ wrapper/VRChat.API/Client/VRChat.cs | 68 +++++++++++- .../VRChat.API/Client/VRChatClientBuilder.cs | 11 +- .../Model/GeneratedModelExtensions.cs | 31 ++++++ wrapper/VRChat.API/VRChat.API.Wrapper.csproj | 32 ------ 14 files changed, 213 insertions(+), 137 deletions(-) create mode 100644 wrapper/VRChat.API/Model/GeneratedModelExtensions.cs delete mode 100644 wrapper/VRChat.API/VRChat.API.Wrapper.csproj diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7296baad..0b3044a4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -96,6 +96,10 @@ jobs: with: repository: vrchatapi/vrchatapi-csharp + # The artifact is overlaid onto the checkout rather than replacing it, so anything the + # generator stops emitting would otherwise survive in src/ forever. + - run: rm -rf src + - uses: actions/download-artifact@v8 with: name: csharp-generated diff --git a/.openapi-generator-ignore b/.openapi-generator-ignore index 593af131..a97986c4 100644 --- a/.openapi-generator-ignore +++ b/.openapi-generator-ignore @@ -22,6 +22,16 @@ # Then explicitly reverse the ignore rule for a single file: #!docs/README.md +# Files the generator emits that this repository does not ship. Listing them here stops +# them being written at all, rather than deleting them afterwards in generate.sh. README.md VRChat.API.sln -src/VRChat.API.Test/ \ No newline at end of file +src/VRChat.API.Test/** +docs/** +api/** +appveyor.yml +git_push.sh +build.sh +build.bat +mono_nunit_test.sh +nuget.exe \ No newline at end of file diff --git a/VRChat.API.sln b/VRChat.API.sln index 8dabd6cf..ad51a884 100644 --- a/VRChat.API.sln +++ b/VRChat.API.sln @@ -4,8 +4,6 @@ VisualStudioVersion = 18.0.11205.157 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRChat.API", "src\VRChat.API\VRChat.API.csproj", "{CFFD58F6-C881-46CB-BD52-445A8A8A8710}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRChat.API.Wrapper", "wrapper\VRChat.API\VRChat.API.Wrapper.csproj", "{A38F1B7D-A1FF-B23E-08C3-81561FBF1C30}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRChat.API.Extensions.Hosting", "wrapper\VRChat.API.Extensions.Hosting\VRChat.API.Extensions.Hosting.csproj", "{D9043377-EF21-E16A-7F4A-58D093467DC1}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRChat.API.Examples.AspNetCore", "examples\VRChat.API.Examples.AspNetCore\VRChat.API.Examples.AspNetCore.csproj", "{9F172233-1AF6-700F-5F54-423D12F46099}" @@ -26,10 +24,6 @@ Global {CFFD58F6-C881-46CB-BD52-445A8A8A8710}.Debug|Any CPU.Build.0 = Debug|Any CPU {CFFD58F6-C881-46CB-BD52-445A8A8A8710}.Release|Any CPU.ActiveCfg = Release|Any CPU {CFFD58F6-C881-46CB-BD52-445A8A8A8710}.Release|Any CPU.Build.0 = Release|Any CPU - {A38F1B7D-A1FF-B23E-08C3-81561FBF1C30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A38F1B7D-A1FF-B23E-08C3-81561FBF1C30}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A38F1B7D-A1FF-B23E-08C3-81561FBF1C30}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A38F1B7D-A1FF-B23E-08C3-81561FBF1C30}.Release|Any CPU.Build.0 = Release|Any CPU {D9043377-EF21-E16A-7F4A-58D093467DC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D9043377-EF21-E16A-7F4A-58D093467DC1}.Debug|Any CPU.Build.0 = Debug|Any CPU {D9043377-EF21-E16A-7F4A-58D093467DC1}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/generate.sh b/generate.sh index a6f4ae72..b9044a72 100755 --- a/generate.sh +++ b/generate.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -euo pipefail if [ ${#} -le 1 ] then @@ -6,87 +7,53 @@ then exit 1 fi -rm src docs *.nupkg *.snupkg -rf +spec="${1}" +version="${2}" +# Applies a sed expression to a file, but only after confirming the text it targets is still +# there. sed exits 0 when it matches nothing, so without this guard a patch that stopped +# applying after a generator upgrade would fail silently. +patch_generated() { + local file="${1}" probe="${2}" expression="${3}" + + if ! grep -qF "${probe}" "${file}" + then + echo "generate.sh: '${probe}' is no longer present in ${file}; the patch below is stale" >&2 + exit 1 + fi + + sed -i "${expression}" "${file}" +} + +rm -rf src docs ./*.nupkg ./*.snupkg + +# Output this repository does not ship is listed in .openapi-generator-ignore, so it is never +# written. Everything VRChat-specific about the generated code lives in ./templates, and the +# csproj customisation in ./templates/netcore_project.additions.mustache. openapi-generator generate \ -g csharp \ -t ./templates \ --library httpclient \ ---additional-properties=packageGuid=1c420561-97f1-4810-ad2d-cd344d27170a,packageName=VRChat.API,packageTags=vrchat,packageVersion="${2}",targetFramework=net8.0,licenseId=MIT,equatable=true \ +--additional-properties=packageGuid=1c420561-97f1-4810-ad2d-cd344d27170a,packageName=VRChat.API,packageVersion="${version}",targetFramework=net8.0,licenseId=MIT,equatable=true,packageAuthors="VRChat API Docs Community",packageCompany="VRChat API Docs Community",packageTitle="VRChat API Library for .NET",packageDescription="VRChat API Library for .NET",packageCopyright="Copyright © 2021 Owners of GitHub organisation \"vrchatapi\" and individual contributors." \ --git-user-id=vrchatapi \ --git-repo-id=vrchatapi-csharp \ -o . \ --i "${1}" \ +-i "${spec}" \ --http-user-agent="vrchatapi-csharp" -rm build.sh -rm build.bat -rm git_push.sh -rm mono_nunit_test.sh -rm nuget.exe -rm appveyor.yml - -rm -rf docs/ -rm -rf api/ -rm -rf src/VRChat.API.Test/ - -# Move wrapper code to src/VRChat.API/Client/ -cp -r wrapper/VRChat.API/Client/* src/VRChat.API/Client/ - cp wrapper/VRChat.API.Extensions.Hosting/vrc_cat.ico src/VRChat.API/vrc_cat.ico cp wrapper/VRChat.API.Extensions.Hosting/vrc_cat.png src/VRChat.API/vrc_cat.png -# Fix cookie handling -for file in $(find ./src/VRChat.API -name '*.cs'); do - sed -i 's/new Cookie("auth", this.Configuration.GetApiKeyWithPrefix("auth"))/new Cookie("auth", this.Configuration.GetApiKeyWithPrefix("auth"), "\/", "api.vrchat.cloud")/g' $file - sed -i 's/new Cookie("twoFactorAuth", this.Configuration.GetApiKeyWithPrefix("twoFactorAuth"))/new Cookie("twoFactorAuth", this.Configuration.GetApiKeyWithPrefix("twoFactorAuth"), "\/", "api.vrchat.cloud")/g' $file - sed -i 's/new Cookie(cookie.Name, cookie.Value)/new Cookie(cookie.Name, cookie.Value, cookie.Path, cookie.Domain)/g' $file -done - -# Add ITwoFactorCode to two-factor authentication types -sed -i 's/\(class.*IValidatableObject\)/\1, ITwoFactorCode/' src/VRChat.API/Model/TwoFactorAuthCode.cs -sed -i 's/\(class.*IValidatableObject\)/\1, ITwoFactorCode/' src/VRChat.API/Model/TwoFactorEmailCode.cs - -# Add icons and readme to package -sed -i ':a;N;$!ba;s|\(.*\)|\1\t \n\t \n\t True\n\t \\\n\t \n\t \n\t True\n\t \\\n\t \n |' src/VRChat.API/VRChat.API.csproj -sed -i '/vrchat<\/PackageTags>/a\ vrc_cat.ico' src/VRChat.API/VRChat.API.csproj - -# Adjust package tags -sed -i 's/vrchat<\/PackageTags>/vrchat,vrcapi,vrc-api,vrc<\/PackageTags>/' src/VRChat.API/VRChat.API.csproj - -# Fix username and password encoding -sed -i 's/VRChat.API.Client.ClientUtils.Base64Encode(this.Configuration.Username + \":\" + this.Configuration.Password)/VRChat.API.Client.ClientUtils.Base64Encode(System.Web.HttpUtility.UrlEncode(this.Configuration.Username) + ":" + System.Web.HttpUtility.UrlEncode(this.Configuration.Password))/g' src/VRChat.API/Api/AuthenticationApi.cs - -# Fix fields in csproj -sed -i 's/OpenAPI Library/VRChat API Library for .NET/' src/VRChat.API/VRChat.API.csproj -sed -i 's/A library generated from a OpenAPI doc/VRChat API Library for .NET/' src/VRChat.API/VRChat.API.csproj -sed -i 's/No Copyright/Copyright © 2021 Owners of GitHub organisation "vrchatapi" and individual contributors./' src/VRChat.API/VRChat.API.csproj -sed -i 's/OpenAPI/VRChat API Docs Community/' src/VRChat.API/VRChat.API.csproj -sed -i 's/Minor update/Automated deployment/' src/VRChat.API/VRChat.API.csproj - -# Fix failure to compile -sed -i 's/false<\/GenerateAssemblyInfo>/true<\/GenerateAssemblyInfo>/' src/VRChat.API/VRChat.API.csproj - -# Update VRChat.API.Extensions.Hosting version -sed -i "s|[^<]*|${2}|g" wrapper/VRChat.API.Extensions.Hosting/VRChat.API.Extensions.Hosting.csproj - -# Add README.md to fields -sed -i '/PackageTags/a \ README.md<\/PackageReadmeFile>' src/VRChat.API/VRChat.API.csproj -sed -i '/System.ComponentModel.Annotations/a \ ' src/VRChat.API/VRChat.API.csproj - -# Add Otp.NET package to project -sed -i '/JsonSubTypes/a \ ' src/VRChat.API/VRChat.API.csproj - -# Make CurrentUser fields optional for 2FA response compatibility -sed -i 's/IsRequired = true/IsRequired = false/g' src/VRChat.API/Model/CurrentUser.cs - -# Add RequiresTwoFactorAuth property to CurrentUser -sed -i '/public string UserIcon { get; set; }/a\\n /// \n /// An array of two-factor authentication methods available to use to with two factor authentication.\n /// \n [DataMember(Name = "requiresTwoFactorAuth", IsRequired = false, EmitDefaultValue = true)]\n public List RequiresTwoFactorAuth { get; set; }' src/VRChat.API/Model/CurrentUser.cs - -# Remove messily pasted markdown at top of every file -for i in src/VRChat.API/*/*.cs; do - sed -i '/VRChat API Banner/d' $i -done - cp README.md src/VRChat.API/ cp README.md src/ + +# The only edit to generated code that no template or partial class can express: CurrentUser is +# also returned mid-login, before two-factor verification, with most of its fields absent. The +# specification marks them required, which makes deserialising that response throw, so the +# generated attributes are relaxed for this one model. +patch_generated src/VRChat.API/Model/CurrentUser.cs \ + 'IsRequired = true' 's/IsRequired = true/IsRequired = false/g' + +# VRChat.API.Extensions.Hosting is hand-maintained, so its version is stamped rather than generated. +patch_generated wrapper/VRChat.API.Extensions.Hosting/VRChat.API.Extensions.Hosting.csproj \ + '' "s|[^<]*|${version}|g" diff --git a/templates/libraries/httpclient/api.mustache b/templates/libraries/httpclient/api.mustache index 6b5b86e4..e5a6b72c 100644 --- a/templates/libraries/httpclient/api.mustache +++ b/templates/libraries/httpclient/api.mustache @@ -487,7 +487,7 @@ namespace {{packageName}}.{{apiPackage}} // cookie parameter support if (!string.IsNullOrEmpty(this.Configuration.GetApiKeyWithPrefix("{{keyParamName}}"))) { - localVarRequestOptions.Cookies.Add(new Cookie("{{keyParamName}}", this.Configuration.GetApiKeyWithPrefix("{{keyParamName}}"))); + localVarRequestOptions.Cookies.Add(new Cookie("{{keyParamName}}", this.Configuration.GetApiKeyWithPrefix("{{keyParamName}}"), "/", "api.vrchat.cloud")); } {{/isKeyInCookie}} {{#isKeyInHeader}} @@ -507,7 +507,7 @@ namespace {{packageName}}.{{apiPackage}} // http basic authentication required if (!string.IsNullOrEmpty(this.Configuration.Username) || !string.IsNullOrEmpty(this.Configuration.Password) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) { - localVarRequestOptions.HeaderParameters.Add("Authorization", "Basic " + {{packageName}}.Client.ClientUtils.Base64Encode(this.Configuration.Username + ":" + this.Configuration.Password)); + localVarRequestOptions.HeaderParameters.Add("Authorization", "Basic " + {{packageName}}.Client.ClientUtils.Base64Encode(System.Web.HttpUtility.UrlEncode(this.Configuration.Username) + ":" + System.Web.HttpUtility.UrlEncode(this.Configuration.Password))); } {{/isBasicBasic}} {{#isBasicBearer}} @@ -721,7 +721,7 @@ namespace {{packageName}}.{{apiPackage}} // cookie parameter support if (!string.IsNullOrEmpty(this.Configuration.GetApiKeyWithPrefix("{{keyParamName}}"))) { - localVarRequestOptions.Cookies.Add(new Cookie("{{keyParamName}}", this.Configuration.GetApiKeyWithPrefix("{{keyParamName}}"))); + localVarRequestOptions.Cookies.Add(new Cookie("{{keyParamName}}", this.Configuration.GetApiKeyWithPrefix("{{keyParamName}}"), "/", "api.vrchat.cloud")); } {{/isKeyInCookie}} {{#isKeyInHeader}} @@ -742,7 +742,7 @@ namespace {{packageName}}.{{apiPackage}} // http basic authentication required if (!string.IsNullOrEmpty(this.Configuration.Username) || !string.IsNullOrEmpty(this.Configuration.Password) && !localVarRequestOptions.HeaderParameters.ContainsKey("Authorization")) { - localVarRequestOptions.HeaderParameters.Add("Authorization", "Basic " + {{packageName}}.Client.ClientUtils.Base64Encode(this.Configuration.Username + ":" + this.Configuration.Password)); + localVarRequestOptions.HeaderParameters.Add("Authorization", "Basic " + {{packageName}}.Client.ClientUtils.Base64Encode(System.Web.HttpUtility.UrlEncode(this.Configuration.Username) + ":" + System.Web.HttpUtility.UrlEncode(this.Configuration.Password))); } {{/isBasicBasic}} {{#isBasicBearer}} diff --git a/templates/modelGeneric.mustache b/templates/modelGeneric.mustache index fca0622c..087dcd6c 100644 --- a/templates/modelGeneric.mustache +++ b/templates/modelGeneric.mustache @@ -364,13 +364,13 @@ return {{#vars}}{{#parent}}base.Equals(input) && {{/parent}}{{^isContainer}} ( this.{{name}} == input.{{name}} || - {{^vendorExtensions.x-is-value-type}} + {{^vendorExtensions.x-csharp-value-type}} (this.{{name}} != null && this.{{name}}.Equals(input.{{name}})) - {{/vendorExtensions.x-is-value-type}} - {{#vendorExtensions.x-is-value-type}} + {{/vendorExtensions.x-csharp-value-type}} + {{#vendorExtensions.x-csharp-value-type}} this.{{name}}.Equals(input.{{name}}) - {{/vendorExtensions.x-is-value-type}} + {{/vendorExtensions.x-csharp-value-type}} ){{^-last}} && {{/-last}}{{/isContainer}}{{#isContainer}} ( this.{{name}} == input.{{name}} || @@ -399,15 +399,15 @@ int hashCode = 41; {{/parent}} {{#vars}} - {{^vendorExtensions.x-is-value-type}} + {{^vendorExtensions.x-csharp-value-type}} if (this.{{name}} != null) { hashCode = (hashCode * 59) + this.{{name}}.GetHashCode(); } - {{/vendorExtensions.x-is-value-type}} - {{#vendorExtensions.x-is-value-type}} + {{/vendorExtensions.x-csharp-value-type}} + {{#vendorExtensions.x-csharp-value-type}} hashCode = (hashCode * 59) + this.{{name}}.GetHashCode(); - {{/vendorExtensions.x-is-value-type}} + {{/vendorExtensions.x-csharp-value-type}} {{/vars}} {{#isAdditionalPropertiesTrue}} if (this.AdditionalProperties != null) diff --git a/templates/netcore_project.additions.mustache b/templates/netcore_project.additions.mustache index 8c6f3ad5..69acd212 100644 --- a/templates/netcore_project.additions.mustache +++ b/templates/netcore_project.additions.mustache @@ -1 +1,39 @@ -{{! if needed users can add this file to their templates folder to append to the csproj }} \ No newline at end of file +{{! + Everything VRChat-specific about the generated csproj lives here, in the generator's own + extension point, rather than being sed-patched into the output afterwards. Rendered as the + last children of , so these PropertyGroup values override the ones above. +}} + + + true + + vrchat,vrcapi,vrc-api,vrc + Automated deployment + README.md + vrc_cat.ico + + + + + + + + + + + + + + + + True + \ + + + True + \ + + diff --git a/templates/partial_header.mustache b/templates/partial_header.mustache index 1562058a..7257ee65 100644 --- a/templates/partial_header.mustache +++ b/templates/partial_header.mustache @@ -3,10 +3,6 @@ * {{{.}}} * {{/appName}} - {{#appDescription}} - * {{{.}}} - * - {{/appDescription}} {{#version}} * The version of the OpenAPI document: {{{.}}} {{/version}} diff --git a/wrapper/VRChat.API.Extensions.Hosting/DefaultVRChatClientFactory.cs b/wrapper/VRChat.API.Extensions.Hosting/DefaultVRChatClientFactory.cs index 5f07958d..db635867 100644 --- a/wrapper/VRChat.API.Extensions.Hosting/DefaultVRChatClientFactory.cs +++ b/wrapper/VRChat.API.Extensions.Hosting/DefaultVRChatClientFactory.cs @@ -72,9 +72,8 @@ public bool TryAddClient(string clientName, VRChatClientBuilder vcb, bool overri { if (_builders.ContainsKey(clientName) && !overrideIfExists) // So that the default may be registered in the case of it being nonexistant return false; - else - _builders.Add(clientName, vcb); + _builders[clientName] = vcb; // Indexer rather than Add, so overrideIfExists actually overrides return true; } } diff --git a/wrapper/VRChat.API.Extensions.Hosting/IVRChatClientFactory.cs b/wrapper/VRChat.API.Extensions.Hosting/IVRChatClientFactory.cs index 83bb93d8..597da234 100644 --- a/wrapper/VRChat.API.Extensions.Hosting/IVRChatClientFactory.cs +++ b/wrapper/VRChat.API.Extensions.Hosting/IVRChatClientFactory.cs @@ -50,6 +50,16 @@ public interface IVRChatClientFactory /// Task LoginClientAsync(string name = "vrc_default", bool throwOnFail = false, CancellationToken ct = default); + /// + /// Registers a under a name, so + /// can later build a client from it. + /// + /// The name to register the builder under. + /// The to register. + /// Whether to replace an existing registration under the same name. + /// if the builder was registered; if a builder + /// is already registered under and + /// is . bool TryAddClient(string clientName, VRChatClientBuilder vcb, bool overrideIfExists = false); } } \ No newline at end of file diff --git a/wrapper/VRChat.API/Client/VRChat.cs b/wrapper/VRChat.API/Client/VRChat.cs index 9223ae29..fba7e622 100644 --- a/wrapper/VRChat.API/Client/VRChat.cs +++ b/wrapper/VRChat.API/Client/VRChat.cs @@ -203,6 +203,13 @@ public interface IVRChat Task LoginWithExternalCodeAsync(Func, ITwoFactorCode> codeAction, CancellationToken ct = default); } + /// + /// The default implementation, exposing every VRChat API surface through a + /// single client that shares one , cookie container and + /// . + /// + /// Instances are created through , or through + /// when you need to supply the underlying HTTP plumbing yourself. public class VRChatClient : IVRChat { private VRChatClient(Configuration configuration, string twoFactorSecret = null, ApiClient client = null, HttpClient httpClient = null, HttpClientHandler httpClientHandler = null) @@ -266,42 +273,93 @@ private VRChatClient(Configuration configuration, string twoFactorSecret = null, private readonly PlayermoderationApi _moderations; private readonly AuthenticationApi _authentication; + /// + /// The every API surface on this client was built with. + /// public Configuration Configuration => _configuration; + + /// + /// The that performs the underlying requests for every API surface on this client. + /// public ApiClient Client => _client; + + /// + /// The shared by every API surface on this client. + /// public HttpClient HttpClient => _httpClient; + + /// + /// The backing , whose + /// holds the session cookies returned by . + /// public HttpClientHandler HttpClientHandler => _httpClientHandler; + /// public IJamsApi Jams => _jams; + /// public IFilesApi Files => _files; + /// public IUsersApi Users => _users; + /// public IPropsApi Props => _props; + /// public IPrintsApi Prints => _prints; + /// public IGroupsApi Groups => _groups; + /// public IWorldsApi Worlds => _worlds; + /// public IInviteApi Invites => _invites; + /// public IAvatarsApi Avatars => _avatars; + /// public IEconomyApi Economy => _economy; + /// public IFriendsApi Friends => _friends; + /// public ICalendarApi Calendar => _calendar; + /// public IInventoryApi Inventory => _inventory; + /// public IInstancesApi Instances => _instances; + /// public IFavoritesApi Favorites => _favorites; + /// public IMiscellaneousApi Miscellaneous => _miscellaneous; + /// public INotificationsApi Notifications => _notifications; + /// public IPlayermoderationApi Moderations => _moderations; + /// public IAuthenticationApi Authentication => _authentication; + /// public bool IsLoggedIn { get; private set; } - // Creates a new VRChatClient, compatible with IVRChat - public static VRChatClient Create(Configuration configuration, string twoFactorSecret, ApiClient client, HttpClient httpClient, HttpClientHandler handler) => - new VRChatClient(configuration, twoFactorSecret, client, httpClient, handler); - + /// + /// Creates a new from pre-built components. + /// + /// Prefer unless you need to control the HTTP plumbing + /// yourself. Any argument left is replaced with a default instance, and a + /// yields a handler with cookies enabled. + /// The to build every API surface with. + /// The TOTP secret used by , or + /// if you intend to log in via . + /// The to issue requests through. + /// The to send requests with. + /// The handler backing ; its cookie + /// container carries the session returned by . + /// A new . It is not logged in until one of the login methods succeeds. + public static VRChatClient Create(Configuration configuration, string twoFactorSecret, ApiClient client, HttpClient httpClient, HttpClientHandler httpClientHandler) => + new VRChatClient(configuration, twoFactorSecret, client, httpClient, httpClientHandler); + + /// public List GetCookies() { return _httpClientHandler.CookieContainer?.GetAllCookies().ToList(); } + /// public async Task TryLoginAsync(CancellationToken ct = default) { CurrentUser user = null; @@ -317,6 +375,7 @@ public async Task TryLoginAsync(CancellationToken ct = defaul return new VRChatLoginResult(user == null, null); } + /// public async Task LoginAsync(CancellationToken ct = default) { if (_twoFactorSecret == null) @@ -348,6 +407,7 @@ public async Task LoginAsync(CancellationToken ct = default) return response.StatusCode == HttpStatusCode.OK ? user : null; } + /// public async Task LoginWithExternalCodeAsync(Func, ITwoFactorCode> codeAction, CancellationToken ct = default) { ApiResponse response = await this.Authentication.GetCurrentUserWithHttpInfoAsync(cancellationToken: ct); diff --git a/wrapper/VRChat.API/Client/VRChatClientBuilder.cs b/wrapper/VRChat.API/Client/VRChatClientBuilder.cs index 0d664542..3d3fd799 100644 --- a/wrapper/VRChat.API/Client/VRChatClientBuilder.cs +++ b/wrapper/VRChat.API/Client/VRChatClientBuilder.cs @@ -64,13 +64,12 @@ public static VRChatClientBuilder From(Configuration incomingConfiguration, ApiC new VRChatClientBuilder(incomingConfiguration, incomingClient); /// - /// + /// Sets the username, password and TOTP secret in one call. /// - /// - /// - /// - /// - /// + /// The username to authenticate with. + /// The password to authenticate with. + /// The TOTP secret used to answer a two-factor challenge. + /// This , so calls can be chained. public VRChatClientBuilder WithCredentials(string username, string password, string twoFactorSecret) => this .WithUsername(username) .WithPassword(password) diff --git a/wrapper/VRChat.API/Model/GeneratedModelExtensions.cs b/wrapper/VRChat.API/Model/GeneratedModelExtensions.cs new file mode 100644 index 00000000..36ebeca2 --- /dev/null +++ b/wrapper/VRChat.API/Model/GeneratedModelExtensions.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace VRChat.API.Model +{ + /// + /// Additions to the generated , so it can be passed wherever a + /// two-factor code is accepted. + /// + public partial class TwoFactorAuthCode : ITwoFactorCode { } + + /// + /// Additions to the generated , so it can be passed wherever a + /// two-factor code is accepted. + /// + public partial class TwoFactorEmailCode : ITwoFactorCode { } + + /// + /// Additions to the generated . + /// + public partial class CurrentUser + { + /// + /// An array of two-factor authentication methods available to use to with two factor authentication. + /// + /// The API returns this field on the current-user response while a login is pending + /// two-factor verification, but the specification does not model it, so it is declared here. + [DataMember(Name = "requiresTwoFactorAuth", IsRequired = false, EmitDefaultValue = true)] + public List RequiresTwoFactorAuth { get; set; } + } +} diff --git a/wrapper/VRChat.API/VRChat.API.Wrapper.csproj b/wrapper/VRChat.API/VRChat.API.Wrapper.csproj deleted file mode 100644 index dc196511..00000000 --- a/wrapper/VRChat.API/VRChat.API.Wrapper.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - false - net8.0 - VRChat.API.Wrapper - VRChat.API.Wrapper - Library - VRChat API Docs Community - VRChat API Docs Community - VRChat API Library for .NET - VRChat API Library for .NET - Copyright © 2021 Owners of GitHub organisation "vrchatapi" and individual contributors. - VRChat.API - 1.20.5 - bin\$(Configuration)\$(TargetFramework)\VRChat.API.xml - MIT - https://github.com/vrchatapi/vrchatapi-csharp.git - git - Automated deployment - vrchat - README.md - - - - - - - - - - From 845570ec1f24eee3701e8a2a5c9d14a54b25b8b8 Mon Sep 17 00:00:00 2001 From: Sarmad Wahab Date: Wed, 2 Sep 2026 23:59:55 -0500 Subject: [PATCH 2/3] fix: preserve parentheses in URIs, document VRChatClientBuilder Two fixes that the template restructuring in the previous commit makes straightforward. Parentheses (#37, by @Powerbyte7) VRChat answers 400 "malformed url" when "(" and ")" arrive percent-encoded, and instance IDs contain them: wrld_0000:12345~group(grp_0000)~groupAccessType(plus)~region(use) #37 restored them with a sed over the generated WebRequestPathBuilder.cs. That file comes from templates/WebRequestPathBuilder.mustache, so the fix goes there instead, behind one Escape helper used by both the path and query paths rather than a chained Replace at each call site. Un-escaping is safe because Uri.EscapeDataString encodes "%" first, so a literal "%28" in a value becomes "%2528" and cannot be corrupted. XML docs (#32) Twelve VRChatClientBuilder members carried empty , and tags. These do not raise CS1591, because a tag is present, so they survived the warning sweep while being just as useless to a caller. Written out, including what WithApplication is for and why VRChat wants it. While writing them: WithAuthCookie set the twoFactorAuth key from the auth argument. Two-factor cookies passed to it were discarded and the auth token was stored twice. Left alone, because it changes authentication behaviour and cannot be verified without a live account: WithAuthCookie stores cookie values with AddApiKeyPrefix rather than AddApiKey, so GetApiKeyWithPrefix returns " ", the token with a trailing space, since no key value is ever set. Regenerated and rebuilt: still 0 warnings, 0 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CpDekr34WgDkSS5myqf8m9 --- templates/WebRequestPathBuilder.mustache | 15 +++- .../VRChat.API/Client/VRChatClientBuilder.cs | 88 +++++++++++-------- 2 files changed, 66 insertions(+), 37 deletions(-) diff --git a/templates/WebRequestPathBuilder.mustache b/templates/WebRequestPathBuilder.mustache index cc811ae4..a63cf080 100644 --- a/templates/WebRequestPathBuilder.mustache +++ b/templates/WebRequestPathBuilder.mustache @@ -18,11 +18,22 @@ namespace {{packageName}}.Client _path = path; } + /// + /// Percent-encodes a value, then restores "(" and ")". + /// + /// VRChat's API answers 400 "malformed url" when parentheses arrive + /// percent-encoded, and instance IDs contain them, for example + /// wrld_0000:12345~group(grp_0000)~groupAccessType(plus)~region(use). + private static string Escape(string value) + { + return Uri.EscapeDataString(value).Replace("%28", "(").Replace("%29", ")"); + } + public void AddPathParameters(Dictionary parameters) { foreach (var parameter in parameters) { - _path = _path.Replace("{" + parameter.Key + "}", Uri.EscapeDataString(parameter.Value)); + _path = _path.Replace("{" + parameter.Key + "}", Escape(parameter.Value)); } } @@ -32,7 +43,7 @@ namespace {{packageName}}.Client { foreach (var value in parameter.Value) { - _query = _query + parameter.Key + "=" + Uri.EscapeDataString(value) + "&"; + _query = _query + parameter.Key + "=" + Escape(value) + "&"; } } } diff --git a/wrapper/VRChat.API/Client/VRChatClientBuilder.cs b/wrapper/VRChat.API/Client/VRChatClientBuilder.cs index 3d3fd799..fc8b949d 100644 --- a/wrapper/VRChat.API/Client/VRChatClientBuilder.cs +++ b/wrapper/VRChat.API/Client/VRChatClientBuilder.cs @@ -76,19 +76,19 @@ public VRChatClientBuilder WithCredentials(string username, string password, str .WithTwoFactorSecret(twoFactorSecret); /// - /// + /// Sets the username and password, for an account without two-factor authentication. /// - /// - /// - /// + /// The username to authenticate with. + /// The password to authenticate with. + /// This , so calls can be chained. public VRChatClientBuilder WithCredentials(string username, string password) => this.WithCredentials(username, password, null); /// - /// + /// Sets the username to authenticate with. /// - /// - /// + /// The account's username. VRChat also accepts the email address. + /// This , so calls can be chained. public VRChatClientBuilder WithUsername(string username) { _configuration.Username = username; @@ -96,10 +96,10 @@ public VRChatClientBuilder WithUsername(string username) } /// - /// + /// Sets the password to authenticate with. /// - /// - /// + /// The account's password. + /// This , so calls can be chained. public VRChatClientBuilder WithPassword(string password) { _configuration.Password = password; @@ -107,10 +107,13 @@ public VRChatClientBuilder WithPassword(string password) } /// - /// + /// Sets the TOTP secret used to answer a two-factor challenge. /// - /// - /// + /// This is the base32 secret shown when two-factor authentication is set up, not a + /// six-digit code. It is required by ; + /// without it, use to supply codes yourself. + /// The account's base32 TOTP secret. + /// This , so calls can be chained. public VRChatClientBuilder WithTwoFactorSecret(string twoFactorSecret) { _twoFactorSecret = twoFactorSecret; @@ -118,27 +121,31 @@ public VRChatClientBuilder WithTwoFactorSecret(string twoFactorSecret) } /// - /// + /// Authenticates with cookies from an earlier session instead of a username and password. /// - /// - /// - /// + /// Obtain these from on a logged-in client. + /// The auth cookie value. + /// The twoFactorAuth cookie value, which lets the session + /// skip the two-factor challenge. + /// This , so calls can be chained. public VRChatClientBuilder WithAuthCookie(string auth, string twoFactorAuth = null) { if (auth != null) _configuration.AddApiKeyPrefix("auth", auth); if (twoFactorAuth != null) - _configuration.AddApiKeyPrefix("twoFactorAuth", auth); + _configuration.AddApiKeyPrefix("twoFactorAuth", twoFactorAuth); return this; } /// - /// + /// Sets the User-Agent header sent with every request. /// - /// - /// + /// VRChat requires a descriptive User-Agent identifying your application and a way to + /// contact you. Prefer , which formats one correctly. + /// The User-Agent to send, or for the default. + /// This , so calls can be chained. public VRChatClientBuilder WithUserAgent(string userAgent) { _configuration.UserAgent = userAgent ?? _defaultUserAgent; @@ -146,10 +153,10 @@ public VRChatClientBuilder WithUserAgent(string userAgent) } /// - /// + /// Sets how long a request may take before it is abandoned. /// - /// - /// + /// The request timeout. + /// This , so calls can be chained. public VRChatClientBuilder WithTimeout(TimeSpan timeout) { _configuration.Timeout = timeout; // Using Miliseconds over TotalMilliseconds can cause issues when the timespan is empty @@ -157,10 +164,10 @@ public VRChatClientBuilder WithTimeout(TimeSpan timeout) } /// - /// + /// Routes every request through a proxy. /// - /// - /// + /// The proxy to send requests through. + /// This , so calls can be chained. public VRChatClientBuilder WithProxy(WebProxy proxy) { _configuration.Proxy = proxy; @@ -168,17 +175,24 @@ public VRChatClientBuilder WithProxy(WebProxy proxy) } /// - /// + /// Routes every request through the proxy at a URL. /// - /// - /// - /// + /// The proxy's address. + /// Whether to bypass the proxy for local addresses. + /// This , so calls can be chained. public VRChatClientBuilder WithProxy(string url, bool bypass = true) => this.WithProxy(new WebProxy(url, bypass)); /// + /// Sets the User-Agent from your application's details, in the form VRChat asks for. /// + /// Produces {name}/{version} ({contact}), VRChat.API/{libraryVersion}. VRChat may + /// block traffic whose User-Agent does not identify the application and a way to reach its author. + /// Your application's name. + /// Your application's version. + /// How VRChat can reach you, such as an email address or Discord handle. + /// This , so calls can be chained. public VRChatClientBuilder WithApplication(string name, string version, string contact) { var libraryVersion = Assembly.GetExecutingAssembly().GetName().Version!.ToString(); @@ -187,11 +201,15 @@ public VRChatClientBuilder WithApplication(string name, string version, string c } /// - /// + /// Builds the configured client. /// - /// - /// - /// + /// The returned client is not logged in. Call one of the login methods on + /// , or supply cookies through . + /// Whether to allow building a client that has no credentials, + /// which is valid for the endpoints that do not require authentication. + /// The configured client. + /// Thrown when is + /// and neither a username and password nor an auth cookie were set. public IVRChat Build(bool useWithoutCredentials = true) { if (!useWithoutCredentials) From c2a10f1b9eaeaeaec96945973c05641e0721fec6 Mon Sep 17 00:00:00 2001 From: Sarmad Wahab Date: Thu, 3 Sep 2026 00:05:49 -0500 Subject: [PATCH 3/3] fix: store auth cookies as API keys, not key prefixes WithAuthCookie called AddApiKeyPrefix, which writes to ApiKeyPrefix rather than ApiKey. GetApiKeyWithPrefix returns `prefix + " " + value` whenever a prefix exists, and no value was ever set, so both cookies were sent as the token followed by a trailing space: Cookie: auth=authcookie_0000 VRChat evidently tolerated the trailing space, which is why this went unnoticed. AddApiKey stores the value where GetApiKeyWithPrefix reads it, and the header is now exact. These two calls were the only writers of ApiKeyPrefix in the repository, so nothing else depended on the old placement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CpDekr34WgDkSS5myqf8m9 --- wrapper/VRChat.API/Client/VRChatClientBuilder.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wrapper/VRChat.API/Client/VRChatClientBuilder.cs b/wrapper/VRChat.API/Client/VRChatClientBuilder.cs index fc8b949d..8b7a6ce3 100644 --- a/wrapper/VRChat.API/Client/VRChatClientBuilder.cs +++ b/wrapper/VRChat.API/Client/VRChatClientBuilder.cs @@ -130,11 +130,13 @@ public VRChatClientBuilder WithTwoFactorSecret(string twoFactorSecret) /// This , so calls can be chained. public VRChatClientBuilder WithAuthCookie(string auth, string twoFactorAuth = null) { + // AddApiKey, not AddApiKeyPrefix: GetApiKeyWithPrefix returns `prefix + " " + value`, so + // storing a cookie as the prefix sent it with a trailing space and no value. if (auth != null) - _configuration.AddApiKeyPrefix("auth", auth); + _configuration.AddApiKey("auth", auth); if (twoFactorAuth != null) - _configuration.AddApiKeyPrefix("twoFactorAuth", twoFactorAuth); + _configuration.AddApiKey("twoFactorAuth", twoFactorAuth); return this; }