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/fetchConfigSchema.js b/src/utils/fetchConfigSchema.js index 426b619af..58a207ab4 100644 --- a/src/utils/fetchConfigSchema.js +++ b/src/utils/fetchConfigSchema.js @@ -1,120 +1,132 @@ -import * as http from '../plugins/http'; +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' }, +}; + +const numericFormats = { + uint8: 'byte', + uint16: 'uint16', + uint32: 'uint32', + 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', + 'maximum': 'maximum', + 'maxItems': 'maxItems', + 'x-valid-values': 'validValues', + 'x-security-critical': 'securityCritical', +}; + +function getTypes(schema) { + if (Array.isArray(schema.type)) return schema.type; + return (schema.type) ? [schema.type] : []; +} -const cachedTypeDefinitions = new Map(); -const cachedStructureDefinitions = new Map(); +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 subtypeRegex = /\[[^\]]+]/g; + const types = getTypes(schema); + const nullable = (types.includes('null')) ? { nullable: true } : {}; -function resolveSubtypes(type) { - const subtypes = type.match(subtypeRegex); - if (!subtypes) return []; - return subtypes.map(subtype => subtype.slice(1, subtype.length - 1)); -} + if (Object.prototype.hasOwnProperty.call(numericFormats, schema.format)) return { type: numericFormats[schema.format], ...nullable }; -async function getStructureDefinition(type) { - if (cachedStructureDefinitions.has(type)) return cachedStructureDefinitions.get(type); + // 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')) { + // ASF marks sets with uniqueItems, every other collection is an ordered list + return { + type: (schema.uniqueItems) ? 'hashSet' : 'list', + values: resolveType(schema.items || {}), + ...nullable, + }; + } - const structureDefinition = http.get(`structure/${encodeURIComponent(type)}`); - cachedStructureDefinitions.set(type, structureDefinition); + if (types.includes('object') && schema.additionalProperties) { + return { + type: 'dictionary', + key: { type: keyTypes[schema['x-key-type']] || 'unknown' }, + value: resolveType(schema.additionalProperties), + ...nullable, + }; + } - return structureDefinition; + return { type: 'unknown' }; } -async function getTypeDefinition(type) { - if (cachedTypeDefinitions.has(type)) return cachedTypeDefinitions.get(type); +function resolvePassthrough(schema) { + const passthrough = {}; - const typeDefinition = http.get(`type/${encodeURIComponent(type)}`); - cachedTypeDefinitions.set(type, typeDefinition); + Object.keys(passthroughKeys).forEach(key => { + if (typeof schema[key] !== 'undefined') passthrough[passthroughKeys[key]] = cloneDeep(schema[key]); + }); - return typeDefinition; + return passthrough; } -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); - } +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 unwindObject(type, typeDefinition) { +async function unwindType(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 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(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; - resolvedTypes.forEach(resolvedType => { - const { param, type } = resolvedType; - const paramName = (typeDefinition.Body[param] !== 'System.UInt64') ? param : `s_${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), paramName, param, - ...type, + ...resolvedType, + ...resolvePassthrough(propertySchema), }; }); 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; diff --git a/src/utils/swagger/parse.js b/src/utils/swagger/parse.js index 9f8b54f2a..95fdc09c6 100644 --- a/src/utils/swagger/parse.js +++ b/src/utils/swagger/parse.js @@ -17,13 +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; + const operation = schema.paths[path]?.[method]; + if (!operation) throw new Error(`No ${method.toUpperCase()} ${path} operation in OpenAPI document`); + return operation; } -export async function getDefinitions(name) { - const schema = await getSchema(); - return schema.components.schemas[name]['x-definition']; +export async function getRequestBody(path, method = 'post') { + 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; }