From 01cd3f19bb5eccacac414f1bd7be88b0608f8af0 Mon Sep 17 00:00:00 2001 From: "Mr.GIitch" Date: Sat, 26 Sep 2026 13:46:34 +0200 Subject: [PATCH 1/5] Use OpenAPI schema for config editor fields, fixes #871 --- src/utils/fetchConfigSchema.js | 175 ++++++++++++++++++--------------- 1 file changed, 93 insertions(+), 82 deletions(-) diff --git a/src/utils/fetchConfigSchema.js b/src/utils/fetchConfigSchema.js index 426b619af..8d3c55c40 100644 --- a/src/utils/fetchConfigSchema.js +++ b/src/utils/fetchConfigSchema.js @@ -1,15 +1,37 @@ import * as http from '../plugins/http'; +import { getType } from './swagger/parse'; -const cachedTypeDefinitions = new Map(); const cachedStructureDefinitions = new Map(); -const subtypeRegex = /\[[^\]]+]/g; - -function resolveSubtypes(type) { - const subtypes = type.match(subtypeRegex); - if (!subtypes) return []; - return subtypes.map(subtype => subtype.slice(1, subtype.length - 1)); -} +// unwindType() keeps accepting the .NET type names the views always passed; they're mapped to OpenAPI component schemas here +const schemaNames = { + 'ArchiSteamFarm.Steam.Storage.BotConfig': 'BotConfig', + 'ArchiSteamFarm.Storage.GlobalConfig': 'GlobalConfig', +}; + +// OpenAPI describes every collection as "array", but ASF uses ImmutableList (ordered) for these properties and ImmutableHashSet for all others +// Verified against /Api/Type of ASF 6.3.9.6 +const orderedListProperties = new Set(['FarmingOrders', 'GamesPlayedWhileIdle']); + +// OpenAPI describes every dictionary key as a string, but ASF uses ImmutableDictionary for these properties +// Verified against /Api/Type of ASF 6.3.9.6 +const uint64KeyedProperties = new Set(['SteamUserPermissions']); + +const numericFormats = { + uint8: 'byte', + uint16: 'uint16', + uint32: 'uint32', + uint64: 'uint64', +}; + +// Constraints carried over from the OpenAPI schema for future use (#1445, #1650), not consumed by the editor yet +const passthroughKeys = { + 'minimum': 'minimum', + 'maximum': 'maximum', + 'maxItems': 'maxItems', + 'x-valid-values': 'validValues', + 'x-security-critical': 'securityCritical', +}; async function getStructureDefinition(type) { if (cachedStructureDefinitions.has(type)) return cachedStructureDefinitions.get(type); @@ -20,101 +42,90 @@ async function getStructureDefinition(type) { return structureDefinition; } -async function getTypeDefinition(type) { - if (cachedTypeDefinitions.has(type)) return cachedTypeDefinitions.get(type); +function getTypes(schema) { + if (Array.isArray(schema.type)) return schema.type; + return (schema.type) ? [schema.type] : []; +} - const typeDefinition = http.get(`type/${encodeURIComponent(type)}`); - cachedTypeDefinitions.set(type, typeDefinition); +function resolveType(schema, param) { + const types = getTypes(schema); + const nullable = (types.includes('null')) ? { nullable: true } : {}; - return typeDefinition; -} + // Enums are $ref'ed component schemas (already dereferenced) carrying their name → value map in x-definition + if (schema['x-definition']) { + return { type: (schema.format === 'flags') ? 'flag' : 'enum', values: { ...schema['x-definition'] } }; + } -async function resolveType(targetType) { - const subtypes = resolveSubtypes(targetType); - - switch (targetType.split('`')[0]) { - case 'System.Boolean': - return { type: 'boolean' }; - case 'System.String': - case 'System.Guid': - return { type: 'string' }; - case 'System.Byte': - return { type: 'byte' }; - case 'System.UInt32': - return { type: 'uint32' }; - case 'System.UInt16': - return { type: 'uint16' }; - case 'System.Collections.Generic.HashSet': - case 'System.Collections.Immutable.ImmutableHashSet': - return { type: 'hashSet', values: await resolveType(subtypes[0]) }; - case 'System.Collections.Immutable.ImmutableList': - return { type: 'list', values: await resolveType(subtypes[0]) }; - case 'System.UInt64': - return { type: 'uint64' }; - case 'System.Collections.Generic.Dictionary': - case 'System.Collections.Immutable.ImmutableDictionary': - return { type: 'dictionary', key: await resolveType(subtypes[0]), value: await resolveType(subtypes[1]) }; - case 'System.Nullable': - const { type } = await resolveType(subtypes[0]); - return { type, nullable: true }; - default: // Complex type - return unwindType(targetType); + if (Object.prototype.hasOwnProperty.call(numericFormats, schema.format)) return { type: numericFormats[schema.format], ...nullable }; + + // Guid is edited as a plain string, same as before the OpenAPI migration + if (schema.format === 'uuid') return { type: 'string', ...nullable }; + + if (types.includes('boolean')) return { type: 'boolean', ...nullable }; + if (types.includes('string')) return { type: 'string', ...nullable }; + + if (types.includes('array')) { + return { + type: (orderedListProperties.has(param)) ? 'list' : 'hashSet', + values: resolveType(schema.items || {}), + ...nullable, + }; } + + if (types.includes('object') && schema.additionalProperties) { + return { + type: 'dictionary', + key: { type: (uint64KeyedProperties.has(param)) ? 'uint64' : 'string' }, + value: resolveType(schema.additionalProperties), + ...nullable, + }; + } + + return { type: 'unknown' }; +} + +function resolvePassthrough(schema) { + const passthrough = {}; + + Object.keys(passthroughKeys).forEach(key => { + if (typeof schema[key] !== 'undefined') passthrough[passthroughKeys[key]] = schema[key]; + }); + + return passthrough; } -async function unwindObject(type, typeDefinition) { +async function unwindType(type) { + const schemaName = schemaNames[type]; + if (!schemaName) throw new Error(`No OpenAPI schema known for type ${type}`); + + const [properties, structureDefinition] = await Promise.all([ + getType(schemaName), + getStructureDefinition(type), + ]); + const resolvedStructure = { type: 'object', body: {}, }; - const [structureDefinition, resolvedTypes] = await Promise.all([ - getStructureDefinition(type), - Promise.all(Object.keys(typeDefinition.Body).map(async param => ({ param, type: await resolveType(typeDefinition.Body[param]) }))), - ]); + Object.keys(properties).forEach(param => { + // uint64 properties are listed twice, the "s_" copy is the string representation ASF serializes to avoid precision loss in JS + // We derive paramName from the numeric property instead of exposing the copy as a separate field + if (param.startsWith('s_')) return; - resolvedTypes.forEach(resolvedType => { - const { param, type } = resolvedType; - const paramName = (typeDefinition.Body[param] !== 'System.UInt64') ? param : `s_${param}`; + const resolvedType = resolveType(properties[param], param); + const paramName = (resolvedType.type !== 'uint64') ? param : `s_${param}`; resolvedStructure.body[param] = { defaultValue: structureDefinition[param], paramName, param, - ...type, + ...resolvedType, + ...resolvePassthrough(properties[param]), }; }); return resolvedStructure; } -function parseEnumValues(rawValues) { - const enumValues = {}; - - Object.keys(rawValues).forEach(key => { - enumValues[key] = parseInt(rawValues[key], 10); - }); - - return enumValues; -} - -async function unwindType(type) { - if (type === 'ArchiSteamFarm.Steam.Storage.BotConfig') getStructureDefinition(type); // Dirty trick, but 30% is 30% - const typeDefinition = await getTypeDefinition(type); - - switch (typeDefinition.Properties.BaseType) { - case 'System.Object': - return unwindObject(type, typeDefinition); - case 'System.Enum': - return { - type: ((typeDefinition.Properties.CustomAttributes || []).includes('System.FlagsAttribute')) ? 'flag' : 'enum', - values: parseEnumValues(typeDefinition.Body), - }; - default: { - const structureDefinition = await getStructureDefinition(type); - return { type: 'unknown', typeDefinition, structureDefinition }; - } - } -} - export default unwindType; From b3fe3383aa7e37267702650c782fe60b4247a7c9 Mon Sep 17 00:00:00 2001 From: "Mr.GIitch" Date: Tue, 13 Oct 2026 19:00:29 +0200 Subject: [PATCH 2/5] Use default, uniqueItems and x-key-type from the ASF 6.3.11 OpenAPI document --- src/utils/fetchConfigSchema.js | 90 +++++++++++++++++----------------- src/utils/swagger/parse.js | 7 +++ 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/src/utils/fetchConfigSchema.js b/src/utils/fetchConfigSchema.js index 8d3c55c40..01468b076 100644 --- a/src/utils/fetchConfigSchema.js +++ b/src/utils/fetchConfigSchema.js @@ -1,22 +1,13 @@ -import * as http from '../plugins/http'; -import { getType } from './swagger/parse'; - -const cachedStructureDefinitions = new Map(); - -// unwindType() keeps accepting the .NET type names the views always passed; they're mapped to OpenAPI component schemas here -const schemaNames = { - 'ArchiSteamFarm.Steam.Storage.BotConfig': 'BotConfig', - 'ArchiSteamFarm.Storage.GlobalConfig': 'GlobalConfig', +import { cloneDeep } from 'lodash-es'; +import { getRequestBody } from './swagger/parse'; + +// unwindType() keeps accepting the .NET type names the views always passed +// The config schemas are read from the request bodies the UI posts them with, which is where ASF inlines them in its OpenAPI document +const configSchemas = { + 'ArchiSteamFarm.Steam.Storage.BotConfig': { path: '/Api/Bot/{botNames}', property: 'BotConfig' }, + 'ArchiSteamFarm.Storage.GlobalConfig': { path: '/Api/ASF', property: 'GlobalConfig' }, }; -// OpenAPI describes every collection as "array", but ASF uses ImmutableList (ordered) for these properties and ImmutableHashSet for all others -// Verified against /Api/Type of ASF 6.3.9.6 -const orderedListProperties = new Set(['FarmingOrders', 'GamesPlayedWhileIdle']); - -// OpenAPI describes every dictionary key as a string, but ASF uses ImmutableDictionary for these properties -// Verified against /Api/Type of ASF 6.3.9.6 -const uint64KeyedProperties = new Set(['SteamUserPermissions']); - const numericFormats = { uint8: 'byte', uint16: 'uint16', @@ -24,6 +15,17 @@ const numericFormats = { uint64: 'uint64', }; +// CLR key types ASF reports for dictionaries in x-key-type +const keyTypes = { + 'System.Boolean': 'boolean', + 'System.Byte': 'byte', + 'System.Guid': 'string', + 'System.String': 'string', + 'System.UInt16': 'uint16', + 'System.UInt32': 'uint32', + 'System.UInt64': 'uint64', +}; + // Constraints carried over from the OpenAPI schema for future use (#1445, #1650), not consumed by the editor yet const passthroughKeys = { 'minimum': 'minimum', @@ -33,29 +35,20 @@ const passthroughKeys = { 'x-security-critical': 'securityCritical', }; -async function getStructureDefinition(type) { - if (cachedStructureDefinitions.has(type)) return cachedStructureDefinitions.get(type); - - const structureDefinition = http.get(`structure/${encodeURIComponent(type)}`); - cachedStructureDefinitions.set(type, structureDefinition); - - return structureDefinition; -} - function getTypes(schema) { if (Array.isArray(schema.type)) return schema.type; return (schema.type) ? [schema.type] : []; } -function resolveType(schema, param) { - const types = getTypes(schema); - const nullable = (types.includes('null')) ? { nullable: true } : {}; - - // Enums are $ref'ed component schemas (already dereferenced) carrying their name → value map in x-definition +function resolveType(schema) { + // Enums carry their name → value map in x-definition, either as $ref'ed component schemas (already dereferenced) or inlined if (schema['x-definition']) { return { type: (schema.format === 'flags') ? 'flag' : 'enum', values: { ...schema['x-definition'] } }; } + const types = getTypes(schema); + const nullable = (types.includes('null')) ? { nullable: true } : {}; + if (Object.prototype.hasOwnProperty.call(numericFormats, schema.format)) return { type: numericFormats[schema.format], ...nullable }; // Guid is edited as a plain string, same as before the OpenAPI migration @@ -65,8 +58,9 @@ function resolveType(schema, param) { if (types.includes('string')) return { type: 'string', ...nullable }; if (types.includes('array')) { + // ASF marks sets with uniqueItems, every other collection is an ordered list return { - type: (orderedListProperties.has(param)) ? 'list' : 'hashSet', + type: (schema.uniqueItems) ? 'hashSet' : 'list', values: resolveType(schema.items || {}), ...nullable, }; @@ -75,7 +69,7 @@ function resolveType(schema, param) { if (types.includes('object') && schema.additionalProperties) { return { type: 'dictionary', - key: { type: (uint64KeyedProperties.has(param)) ? 'uint64' : 'string' }, + key: { type: keyTypes[schema['x-key-type']] || 'unknown' }, value: resolveType(schema.additionalProperties), ...nullable, }; @@ -88,40 +82,48 @@ function resolvePassthrough(schema) { const passthrough = {}; Object.keys(passthroughKeys).forEach(key => { - if (typeof schema[key] !== 'undefined') passthrough[passthroughKeys[key]] = schema[key]; + if (typeof schema[key] !== 'undefined') passthrough[passthroughKeys[key]] = cloneDeep(schema[key]); }); return passthrough; } +function resolveDefaultValue(schema, param, objectDefaults) { + // Properties carry their own default, the object-level default of the whole config is the fallback for the few that don't + const defaultValue = (typeof schema.default !== 'undefined') ? schema.default : objectDefaults[param]; + return cloneDeep(defaultValue); +} + async function unwindType(type) { - const schemaName = schemaNames[type]; - if (!schemaName) throw new Error(`No OpenAPI schema known for type ${type}`); + const configSchema = configSchemas[type]; + if (!configSchema) throw new Error(`No OpenAPI schema known for type ${type}`); + + const requestBody = await getRequestBody(configSchema.path); + const { [configSchema.property]: schema } = requestBody.properties; + if (!schema?.properties) throw new Error(`No ${configSchema.property} schema in ${configSchema.path} request body`); - const [properties, structureDefinition] = await Promise.all([ - getType(schemaName), - getStructureDefinition(type), - ]); + const objectDefaults = schema.default || {}; const resolvedStructure = { type: 'object', body: {}, }; - Object.keys(properties).forEach(param => { + Object.keys(schema.properties).forEach(param => { // uint64 properties are listed twice, the "s_" copy is the string representation ASF serializes to avoid precision loss in JS // We derive paramName from the numeric property instead of exposing the copy as a separate field if (param.startsWith('s_')) return; - const resolvedType = resolveType(properties[param], param); + const propertySchema = schema.properties[param]; + const resolvedType = resolveType(propertySchema); const paramName = (resolvedType.type !== 'uint64') ? param : `s_${param}`; resolvedStructure.body[param] = { - defaultValue: structureDefinition[param], + defaultValue: resolveDefaultValue(propertySchema, param, objectDefaults), paramName, param, ...resolvedType, - ...resolvePassthrough(properties[param]), + ...resolvePassthrough(propertySchema), }; }); diff --git a/src/utils/swagger/parse.js b/src/utils/swagger/parse.js index 9f8b54f2a..35ea813ac 100644 --- a/src/utils/swagger/parse.js +++ b/src/utils/swagger/parse.js @@ -27,3 +27,10 @@ export async function getDefinitions(name) { const schema = await getSchema(); return schema.components.schemas[name]['x-definition']; } + +export async function getRequestBody(path, method = 'post') { + const schema = await getSchema(); + const operation = schema.paths[path]?.[method]; + if (!operation) throw new Error(`No ${method.toUpperCase()} ${path} operation in OpenAPI document`); + return operation.requestBody.content['application/json'].schema; +} From 8bf663f4246e35a38a66adcce41fff0afb8547a9 Mon Sep 17 00:00:00 2001 From: "Mr.GIitch" Date: Tue, 13 Oct 2026 20:07:04 +0200 Subject: [PATCH 3/5] Drop the object-level default fallback, every config property carries its own default since ASF 6.3.11.1 --- src/utils/fetchConfigSchema.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/utils/fetchConfigSchema.js b/src/utils/fetchConfigSchema.js index 01468b076..4d7810ed4 100644 --- a/src/utils/fetchConfigSchema.js +++ b/src/utils/fetchConfigSchema.js @@ -88,12 +88,6 @@ function resolvePassthrough(schema) { return passthrough; } -function resolveDefaultValue(schema, param, objectDefaults) { - // Properties carry their own default, the object-level default of the whole config is the fallback for the few that don't - const defaultValue = (typeof schema.default !== 'undefined') ? schema.default : objectDefaults[param]; - return cloneDeep(defaultValue); -} - async function unwindType(type) { const configSchema = configSchemas[type]; if (!configSchema) throw new Error(`No OpenAPI schema known for type ${type}`); @@ -102,8 +96,6 @@ async function unwindType(type) { const { [configSchema.property]: schema } = requestBody.properties; if (!schema?.properties) throw new Error(`No ${configSchema.property} schema in ${configSchema.path} request body`); - const objectDefaults = schema.default || {}; - const resolvedStructure = { type: 'object', body: {}, @@ -119,7 +111,7 @@ async function unwindType(type) { const paramName = (resolvedType.type !== 'uint64') ? param : `s_${param}`; resolvedStructure.body[param] = { - defaultValue: resolveDefaultValue(propertySchema, param, objectDefaults), + defaultValue: cloneDeep(propertySchema.default), paramName, param, ...resolvedType, From 5d976ef9af23648dc5caa74383280d9956c1d6f1 Mon Sep 17 00:00:00 2001 From: "Mr.GIitch" Date: Tue, 13 Oct 2026 20:07:05 +0200 Subject: [PATCH 4/5] Look up currency codes through the bot response schema instead of by component name --- src/store/modules/asf.js | 6 ++++-- src/utils/swagger/parse.js | 23 ++++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/store/modules/asf.js b/src/store/modules/asf.js index 6d117be35..79ec1513d 100644 --- a/src/store/modules/asf.js +++ b/src/store/modules/asf.js @@ -1,7 +1,7 @@ import * as humanizeDuration from 'humanize-duration'; import Vue from 'vue'; import * as http from '../../plugins/http'; -import { getDefinitions } from '../../utils/swagger/parse'; +import { getResponseBody } from '../../utils/swagger/parse'; const humanizer = humanizeDuration.humanizer({ language: 'shortEn', @@ -87,7 +87,9 @@ export const actions = { } }, setCurrencyCodes: async ({ commit }) => { - const codes = await getDefinitions('ECurrencyCode'); + // Currency codes are the x-definition of the WalletCurrency enum of a bot, looked up through the response of GET /Api/Bot/{botNames} + const { properties: { Result: bots } } = await getResponseBody('/Api/Bot/{botNames}'); + const codes = bots.additionalProperties.properties.WalletCurrency['x-definition']; commit('updateCurrencyCodes', codes); }, }; diff --git a/src/utils/swagger/parse.js b/src/utils/swagger/parse.js index 35ea813ac..95fdc09c6 100644 --- a/src/utils/swagger/parse.js +++ b/src/utils/swagger/parse.js @@ -17,20 +17,21 @@ async function getSchema() { return schema; } -export async function getType(name) { +// ASF may inline a schema where it's used or reference it from components, depending on the version and the usage, +// so schemas must always be looked up through the operation that uses them and never by component name +async function getOperation(path, method) { const schema = await getSchema(); - const { [name]: type } = schema.components.schemas; - return type.properties; -} - -export async function getDefinitions(name) { - const schema = await getSchema(); - return schema.components.schemas[name]['x-definition']; + const operation = schema.paths[path]?.[method]; + if (!operation) throw new Error(`No ${method.toUpperCase()} ${path} operation in OpenAPI document`); + return operation; } export async function getRequestBody(path, method = 'post') { - const schema = await getSchema(); - const operation = schema.paths[path]?.[method]; - if (!operation) throw new Error(`No ${method.toUpperCase()} ${path} operation in OpenAPI document`); + const operation = await getOperation(path, method); return operation.requestBody.content['application/json'].schema; } + +export async function getResponseBody(path, method = 'get', status = '200') { + const operation = await getOperation(path, method); + return operation.responses[status].content['application/json'].schema; +} From 767c2bf5430b2c2c0511fb51525ee50d31548676 Mon Sep 17 00:00:00 2001 From: "Mr.GIitch" Date: Tue, 20 Oct 2026 21:18:41 +0200 Subject: [PATCH 5/5] Give security-critical properties no default so untouched fields are never posted --- src/utils/fetchConfigSchema.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/utils/fetchConfigSchema.js b/src/utils/fetchConfigSchema.js index 4d7810ed4..58a207ab4 100644 --- a/src/utils/fetchConfigSchema.js +++ b/src/utils/fetchConfigSchema.js @@ -88,6 +88,13 @@ function resolvePassthrough(schema) { return passthrough; } +function resolveDefaultValue(schema) { + // ASF never reveals the current value of security-critical properties, so the UI can't tell whether one is set + // They get no default: an untouched field then stays out of the request and ASF keeps the existing value, while a null would clear it + if (schema['x-security-critical']) return undefined; + return cloneDeep(schema.default); +} + async function unwindType(type) { const configSchema = configSchemas[type]; if (!configSchema) throw new Error(`No OpenAPI schema known for type ${type}`); @@ -111,7 +118,7 @@ async function unwindType(type) { const paramName = (resolvedType.type !== 'uint64') ? param : `s_${param}`; resolvedStructure.body[param] = { - defaultValue: cloneDeep(propertySchema.default), + defaultValue: resolveDefaultValue(propertySchema), paramName, param, ...resolvedType,