diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index 873d216..64cc5e5 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -47,6 +47,7 @@ docs/FilesApi.md docs/FinishFileDataUploadRequest.md docs/FriendStatus.md docs/FriendsApi.md +docs/GetCurrentUser200Response.md docs/Group.md docs/GroupAccessType.md docs/GroupAnnouncement.md @@ -132,6 +133,7 @@ docs/TransactionSteamInfo.md docs/TransactionSteamWalletInfo.md docs/TwoFactorAuthCode.md docs/TwoFactorEmailCode.md +docs/TwoFactorRequired.md docs/UnityPackage.md docs/UpdateAvatarRequest.md docs/UpdateFavoriteGroupRequest.md @@ -215,6 +217,7 @@ src/models/file_version.rs src/models/file_version_upload_status.rs src/models/finish_file_data_upload_request.rs src/models/friend_status.rs +src/models/get_current_user_200_response.rs src/models/group.rs src/models/group_access_type.rs src/models/group_announcement.rs @@ -294,6 +297,7 @@ src/models/transaction_steam_info.rs src/models/transaction_steam_wallet_info.rs src/models/two_factor_auth_code.rs src/models/two_factor_email_code.rs +src/models/two_factor_required.rs src/models/unity_package.rs src/models/update_avatar_request.rs src/models/update_favorite_group_request.rs diff --git a/bin/apply_json_patch.js b/bin/apply_json_patch.js new file mode 100755 index 0000000..faf19d7 --- /dev/null +++ b/bin/apply_json_patch.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import { applyPatch } from 'rfc6902' +import { parse, stringify } from 'yaml' + +import fs, { cp } from 'node:fs'; + +const helpText = 'Usage: apply_json_patch.js [output_file]' + +var args = process.argv.slice(2); + +if (args.find(arg => arg === '--help')) { + console.log(helpText) + process.exit(0) +} + +if (args.length < 2) { + console.error('Missing arguments') + console.log(helpText) + process.exit(1) +} + +const patchFile = args[0] +const sourceFile = args[1] +const outputFile = args[2] || sourceFile + +try { + const patch = JSON.parse(fs.readFileSync(patchFile, 'utf8')) + const source = fs.readFileSync(sourceFile, 'utf8') + + const sourceJson = parse(source) + const results = applyPatch(sourceJson, patch) + + results.forEach((result, i) => { + if (result !== null) { + throw new Error('Patch failed at operation ' + i) + } + }) + + fs.writeFileSync(outputFile, stringify(sourceJson, { singleQuote: false, lineWidth: 0, })) +} catch (err) { + console.error(err) +} diff --git a/bin/gen_patch.js b/bin/gen_patch.js new file mode 100755 index 0000000..88c4d7a --- /dev/null +++ b/bin/gen_patch.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { createPatch } from 'rfc6902' +import { parse } from 'yaml' +import fs from 'node:fs'; + +const helpText = 'Usage: gen_patch.js ' + +var args = process.argv.slice(2); + +if (args.find(arg => arg === '--help')) { + console.log(helpText) + process.exit(0) +} + +if (args.length < 3) { + console.error('Missing arguments') + console.log(helpText) + process.exit(1) +} + +const actualFile = args[0] +const patchedFile = args[1] +const outputFile = args[2] + +try { + const actual = fs.readFileSync(actualFile, 'utf8') + const patched = fs.readFileSync(patchedFile, 'utf8') + + const actualJson = parse(actual) + const patchedJson = parse(patched) + + const patch = createPatch(actualJson, patchedJson) + + fs.writeFileSync(outputFile, JSON.stringify(patch, null, 2)) +} catch (err) { + console.error(err) +} diff --git a/docs/AuthenticationApi.md b/docs/AuthenticationApi.md index f818677..805d6d9 100644 --- a/docs/AuthenticationApi.md +++ b/docs/AuthenticationApi.md @@ -80,7 +80,7 @@ Name | Type | Description | Required | Notes ## get_current_user -> models::CurrentUser get_current_user() +> models::GetCurrentUser200Response get_current_user() Login and/or Get Current User Info This endpoint does the following two operations: 1) Checks if you are already logged in by looking for a valid `auth` cookie. If you are have a valid auth cookie then no additional auth-related actions are taken. If you are **not** logged in then it will log you in with the `Authorization` header and set the `auth` cookie. The `auth` cookie will only be sent once. 2) If logged in, this function will also return the CurrentUser object containing detailed information about the currently logged in user. The auth string after `Authorization: Basic {string}` is a base64-encoded string of the username and password, both individually url-encoded, and then joined with a colon. > base64(urlencode(username):urlencode(password)) **WARNING: Session Limit:** Each authentication with login credentials counts as a separate session, out of which you have a limited amount. Make sure to save and reuse the `auth` cookie if you are often restarting the program. The provided API libraries automatically save cookies during runtime, but does not persist during restart. While it can be fine to use username/password during development, expect in production to very fast run into the rate-limit and be temporarily blocked from making new sessions until older ones expire. The exact number of simultaneous sessions is unknown/undisclosed. @@ -91,7 +91,7 @@ This endpoint does not need any parameter. ### Return type -[**models::CurrentUser**](CurrentUser.md) +[**models::GetCurrentUser200Response**](getCurrentUser_200_response.md) ### Authorization diff --git a/docs/GetCurrentUser200Response.md b/docs/GetCurrentUser200Response.md new file mode 100644 index 0000000..b3bcbd7 --- /dev/null +++ b/docs/GetCurrentUser200Response.md @@ -0,0 +1,12 @@ +# GetCurrentUser200Response + +## Enum Variants + +| Name | Description | +|---- | -----| +| CurrentUser | | +| TwoFactorRequired | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/docs/Group.md b/docs/Group.md index 2d93040..7f2859b 100644 --- a/docs/Group.md +++ b/docs/Group.md @@ -22,9 +22,9 @@ Name | Type | Description | Notes **member_count_synced_at** | Option<**String**> | | [optional] **is_verified** | Option<**bool**> | | [optional][default to false] **join_state** | Option<[**models::GroupJoinState**](GroupJoinState.md)> | | [optional] -**tags** | Option<**Vec**> | | [optional] +**tags** | Option<**Vec**> | | [optional] **transfer_target_id** | Option<**String**> | A users unique ID, usually in the form of `usr_c1644b5b-3ca4-45b4-97c6-a2a0de70d469`. Legacy players can have old IDs in the form of `8JoV9XEdpo`. The ID can never be changed. | [optional] -**galleries** | Option<[**Vec**](GroupGallery.md)> | | [optional] +**galleries** | Option<[**Vec**](GroupGallery.md)> | | [optional] **created_at** | Option<**String**> | | [optional] **updated_at** | Option<**String**> | | [optional] **last_post_created_at** | Option<**String**> | | [optional] diff --git a/docs/TwoFactorRequired.md b/docs/TwoFactorRequired.md new file mode 100644 index 0000000..275574b --- /dev/null +++ b/docs/TwoFactorRequired.md @@ -0,0 +1,11 @@ +# TwoFactorRequired + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**requires_two_factor_auth** | **Vec** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/examples/online.rs b/examples/online.rs index 387bcfb..8abdfc1 100644 --- a/examples/online.rs +++ b/examples/online.rs @@ -1,15 +1,78 @@ pub use vrchatapi::apis; +use vrchatapi::models::TwoFactorEmailCode; #[tokio::main] async fn main() { - let mut config = apis::configuration::Configuration::default(); - config.basic_auth = Some((String::from("username"), Some(String::from("password")))); + let config = apis::configuration::Configuration { + // credentials do not belong in source code, use arguments or environment variables + basic_auth: Some((String::from("username"), Some(String::from("password")))), + ..Default::default() + }; - match apis::authentication_api::get_current_user(&config).await.unwrap() { - vrchatapi::models::EitherUserOrTwoFactor::CurrentUser(me) => println!("Username: {}", me.username.unwrap()), - vrchatapi::models::EitherUserOrTwoFactor::RequiresTwoFactorAuth(requires_auth) => println!("The Username requires Auth: {:?}", requires_auth.requires_two_factor_auth) - } + match apis::authentication_api::get_current_user(&config) + .await + .expect("Failed to get current user") + { + vrchatapi::models::GetCurrentUser200Response::CurrentUser(_user) => { + println!("Already logged in"); + } + vrchatapi::models::GetCurrentUser200Response::TwoFactorRequired(two_factor_resp) => { + // The API returns you a list of methods to verify your 2FA, this determines what function to call next + // There are only 2 ways the array of available methods looks, either ['emailOpt'] or ['otp', 'totp'] + // One deals with email verification, the other two with either TOTP or a recovery code generated from the VRChat website - let online = apis::system_api::get_current_online_users(&config).await.unwrap(); + let code = "123456".to_owned(); // grab this via stdin / generate it / read it from email server + + let verified: bool; + if two_factor_resp + .requires_two_factor_auth + .contains(&vrchatapi::models::two_factor_required::RequiresTwoFactorAuth::EmailOtp) + { + let resp = apis::authentication_api::verify2_fa_email_code( + &config, + TwoFactorEmailCode::new(code.clone()), + ) + .await + .expect("Failed toemail 2FA response"); + + verified = resp.verified; + } else { + let resp = apis::authentication_api::verify2_fa( + &config, + vrchatapi::models::TwoFactorAuthCode { code }, + ) + .await + .expect("Failed to get totp 2FA response"); + + // If you have a recovery code, use this method instead + // let resp = apis::authentication_api::verify_recovery_code( + // &config, + // vrchatapi::models::two_factor_auth_code::TwoFactorAuthCode { code }, + // ) + // .await + // .expect("Failed to get recovery code 2FA response"); + + verified = resp.verified; + } + + if !verified { + panic!("Failed to verify 2FA"); + } + } + }; + + let current_user = match apis::authentication_api::get_current_user(&config) + .await + .expect("Failed to get current user") + { + vrchatapi::models::GetCurrentUser200Response::CurrentUser(user) => user, + _ => panic!("Got 2FA response, even after verifying 2FA"), + }; + + println!("Current User: {:?}", current_user.display_name); + + let online = apis::system_api::get_current_online_users(&config) + .await + .expect("Failed to get online users"); println!("Current Online Users: {}", online); -} \ No newline at end of file +} diff --git a/generate.sh b/generate.sh index b11be0f..1462caa 100755 --- a/generate.sh +++ b/generate.sh @@ -1,17 +1,31 @@ #!/usr/bin/env bash +# This script assumes that all npm dependencies are installed (e.g. by a previous step in CI) -# Generate Client +set -eux -o pipefail + +# clean up old files rm src/apis src/models docs -rf +# Download OpenAPI spec +wget https://raw.githubusercontent.com/vrchatapi/specification/gh-pages/openapi.yaml -O openapi.yaml + +# patch openapi.yaml +for p in patches/*.json; do + node bin/apply_json_patch.js $p openapi.yaml +done + +# Generate client ./node_modules/\@openapitools/openapi-generator-cli/main.js generate \ -g rust \ --additional-properties=packageName=vrchatapi,supportAsync=true \ --git-user-id=vrchatapi \ --git-repo-id=vrchatapi-rust \ -o . \ --i https://raw.githubusercontent.com/vrchatapi/specification/gh-pages/openapi.yaml \ +-i openapi.yaml \ --http-user-agent="vrchatapi-rust" -#--global-property debugOperations=true + +# Remove openapi.yaml +rm openapi.yaml # Update entire description (replace entire line, match the random data there) line in Cargo.toml sed -i 's/^description = ".*"/description = "VRChat API Client for Rust"/' Cargo.toml @@ -27,13 +41,9 @@ find src -type f -exec sed -i '/^\s*\/\/\/\s*$/d' {} \; sed -i 's/Client::new()/Client::builder().cookie_store(true).build().unwrap()/g' src/apis/configuration.rs sed -i 's/features = \["json", "multipart"\]/features = \["json", "cookies", "multipart"\]/g' Cargo.toml -#Fix example +# Fix example printf "\n[dev-dependencies]\ntokio = { version = '1', features = ['macros', 'rt-multi-thread'] }" >> Cargo.toml -# https://github.com/vrchatapi/specification/issues/241 -cat patches/2FA_Current_User.rs >> src/models/current_user.rs -sed -i 's/pub use self::current_user::CurrentUser;/pub use self::current_user::{EitherUserOrTwoFactor, CurrentUser};/g' src/models/mod.rs -sed -i 's/Result>/Result>/g' src/apis/authentication_api.rs - +# Format and test cargo build cargo test diff --git a/package.json b/package.json index 1383dec..b9df7ef 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { + "type": "module", "dependencies": { - "@openapitools/openapi-generator-cli": "^2.13.4" + "@openapitools/openapi-generator-cli": "^2.13.4", + "rfc6902": "^5.1.1", + "yaml": "^2.5.0" } } diff --git a/patches/0001_fix_2fa.json b/patches/0001_fix_2fa.json new file mode 100644 index 0000000..faef2a2 --- /dev/null +++ b/patches/0001_fix_2fa.json @@ -0,0 +1,36 @@ +[ + { + "op": "add", + "path": "/components/schemas/TwoFactorRequired", + "value": { + "title": "TwoFactorRequired", + "type": "object", + "properties": { + "requiresTwoFactorAuth": { + "type": "array", + "items": { + "type": "string", + "enum": ["totp", "otp", "emailOtp"] + } + } + }, + "required": ["requiresTwoFactorAuth"] + } + }, + { + "op": "remove", + "path": "/components/responses/CurrentUserLoginResponse/content/application~1json/schema/$ref" + }, + { + "op": "add", + "path": "/components/responses/CurrentUserLoginResponse/content/application~1json/schema/oneOf", + "value": [ + { + "$ref": "#/components/schemas/CurrentUser" + }, + { + "$ref": "#/components/schemas/TwoFactorRequired" + } + ] + } +] diff --git a/patches/2FA_Current_User.rs b/patches/2FA_Current_User.rs deleted file mode 100644 index 967fc3b..0000000 --- a/patches/2FA_Current_User.rs +++ /dev/null @@ -1,12 +0,0 @@ -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum EitherUserOrTwoFactor{ - CurrentUser(CurrentUser), - RequiresTwoFactorAuth(RequiresTwoFactorAuth), -} - -#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct RequiresTwoFactorAuth{ - #[serde(rename = "requiresTwoFactorAuth")] - pub requires_two_factor_auth: Vec -} \ No newline at end of file diff --git a/src/apis/authentication_api.rs b/src/apis/authentication_api.rs index 7b63769..735bcea 100644 --- a/src/apis/authentication_api.rs +++ b/src/apis/authentication_api.rs @@ -147,7 +147,7 @@ pub async fn delete_user(configuration: &configuration::Configuration, user_id: } /// This endpoint does the following two operations: 1) Checks if you are already logged in by looking for a valid `auth` cookie. If you are have a valid auth cookie then no additional auth-related actions are taken. If you are **not** logged in then it will log you in with the `Authorization` header and set the `auth` cookie. The `auth` cookie will only be sent once. 2) If logged in, this function will also return the CurrentUser object containing detailed information about the currently logged in user. The auth string after `Authorization: Basic {string}` is a base64-encoded string of the username and password, both individually url-encoded, and then joined with a colon. > base64(urlencode(username):urlencode(password)) **WARNING: Session Limit:** Each authentication with login credentials counts as a separate session, out of which you have a limited amount. Make sure to save and reuse the `auth` cookie if you are often restarting the program. The provided API libraries automatically save cookies during runtime, but does not persist during restart. While it can be fine to use username/password during development, expect in production to very fast run into the rate-limit and be temporarily blocked from making new sessions until older ones expire. The exact number of simultaneous sessions is unknown/undisclosed. -pub async fn get_current_user(configuration: &configuration::Configuration, ) -> Result> { +pub async fn get_current_user(configuration: &configuration::Configuration, ) -> Result> { let local_var_configuration = configuration; let local_var_client = &local_var_configuration.client; diff --git a/src/models/current_user.rs b/src/models/current_user.rs index c3b8668..2c38325 100644 --- a/src/models/current_user.rs +++ b/src/models/current_user.rs @@ -222,15 +222,3 @@ impl CurrentUser { } } -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum EitherUserOrTwoFactor{ - CurrentUser(CurrentUser), - RequiresTwoFactorAuth(RequiresTwoFactorAuth), -} - -#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct RequiresTwoFactorAuth{ - #[serde(rename = "requiresTwoFactorAuth")] - pub requires_two_factor_auth: Vec -} \ No newline at end of file diff --git a/src/models/get_current_user_200_response.rs b/src/models/get_current_user_200_response.rs new file mode 100644 index 0000000..b6adc3b --- /dev/null +++ b/src/models/get_current_user_200_response.rs @@ -0,0 +1,39 @@ +/* + * VRChat API Documentation + * + * + * Contact: vrchatapi.lpv0t@aries.fyi + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GetCurrentUser200Response { + CurrentUser(Box), + TwoFactorRequired(Box), +} + +impl Default for GetCurrentUser200Response { + fn default() -> Self { + Self::CurrentUser(Default::default()) + } +} +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum RequiresTwoFactorAuth { + #[serde(rename = "totp")] + Totp, + #[serde(rename = "otp")] + Otp, + #[serde(rename = "emailOtp")] + EmailOtp, +} + +impl Default for RequiresTwoFactorAuth { + fn default() -> RequiresTwoFactorAuth { + Self::Totp + } +} + diff --git a/src/models/group.rs b/src/models/group.rs index 3b94caa..54b7d78 100644 --- a/src/models/group.rs +++ b/src/models/group.rs @@ -59,8 +59,8 @@ pub struct Group { pub created_at: Option, #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")] pub updated_at: Option, - #[serde(rename = "lastPostCreatedAt", skip_serializing_if = "Option::is_none")] - pub last_post_created_at: Option, + #[serde(rename = "lastPostCreatedAt", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] + pub last_post_created_at: Option>, #[serde(rename = "onlineMemberCount", skip_serializing_if = "Option::is_none")] pub online_member_count: Option, #[serde(rename = "membershipStatus", skip_serializing_if = "Option::is_none")] diff --git a/src/models/group_my_member.rs b/src/models/group_my_member.rs index 33ae2af..ab3a3c7 100644 --- a/src/models/group_my_member.rs +++ b/src/models/group_my_member.rs @@ -20,8 +20,8 @@ pub struct GroupMyMember { pub user_id: Option, #[serde(rename = "roleIds", skip_serializing_if = "Option::is_none")] pub role_ids: Option>, - #[serde(rename = "acceptedByDisplayName", skip_serializing_if = "Option::is_none")] - pub accepted_by_display_name: Option, + #[serde(rename = "acceptedByDisplayName", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] + pub accepted_by_display_name: Option>, /// A users unique ID, usually in the form of `usr_c1644b5b-3ca4-45b4-97c6-a2a0de70d469`. Legacy players can have old IDs in the form of `8JoV9XEdpo`. The ID can never be changed. #[serde(rename = "acceptedById", skip_serializing_if = "Option::is_none")] pub accepted_by_id: Option, @@ -45,8 +45,8 @@ pub struct GroupMyMember { pub has2_fa: Option, #[serde(rename = "hasJoinedFromPurchase", skip_serializing_if = "Option::is_none")] pub has_joined_from_purchase: Option, - #[serde(rename = "lastPostReadAt", skip_serializing_if = "Option::is_none")] - pub last_post_read_at: Option, + #[serde(rename = "lastPostReadAt", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] + pub last_post_read_at: Option>, #[serde(rename = "mRoleIds", skip_serializing_if = "Option::is_none")] pub m_role_ids: Option>, #[serde(rename = "permissions", skip_serializing_if = "Option::is_none")] diff --git a/src/models/mod.rs b/src/models/mod.rs index 7a6c28e..65d5f05 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -45,7 +45,7 @@ pub use self::create_instance_request::CreateInstanceRequest; pub mod create_world_request; pub use self::create_world_request::CreateWorldRequest; pub mod current_user; -pub use self::current_user::{EitherUserOrTwoFactor, CurrentUser}; +pub use self::current_user::CurrentUser; pub mod current_user_presence; pub use self::current_user_presence::CurrentUserPresence; pub mod deployment_group; @@ -80,6 +80,8 @@ pub mod finish_file_data_upload_request; pub use self::finish_file_data_upload_request::FinishFileDataUploadRequest; pub mod friend_status; pub use self::friend_status::FriendStatus; +pub mod get_current_user_200_response; +pub use self::get_current_user_200_response::GetCurrentUser200Response; pub mod group; pub use self::group::Group; pub mod group_access_type; @@ -236,6 +238,8 @@ pub mod two_factor_auth_code; pub use self::two_factor_auth_code::TwoFactorAuthCode; pub mod two_factor_email_code; pub use self::two_factor_email_code::TwoFactorEmailCode; +pub mod two_factor_required; +pub use self::two_factor_required::TwoFactorRequired; pub mod unity_package; pub use self::unity_package::UnityPackage; pub mod update_avatar_request; diff --git a/src/models/two_factor_required.rs b/src/models/two_factor_required.rs new file mode 100644 index 0000000..b0e5225 --- /dev/null +++ b/src/models/two_factor_required.rs @@ -0,0 +1,40 @@ +/* + * VRChat API Documentation + * + * + * Contact: vrchatapi.lpv0t@aries.fyi + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TwoFactorRequired { + #[serde(rename = "requiresTwoFactorAuth")] + pub requires_two_factor_auth: Vec, +} + +impl TwoFactorRequired { + pub fn new(requires_two_factor_auth: Vec) -> TwoFactorRequired { + TwoFactorRequired { + requires_two_factor_auth, + } + } +} +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum RequiresTwoFactorAuth { + #[serde(rename = "totp")] + Totp, + #[serde(rename = "otp")] + Otp, + #[serde(rename = "emailOtp")] + EmailOtp, +} + +impl Default for RequiresTwoFactorAuth { + fn default() -> RequiresTwoFactorAuth { + Self::Totp + } +} +