Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/store/modules/asf.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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);
},
};
Expand Down
190 changes: 101 additions & 89 deletions src/utils/fetchConfigSchema.js
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 14 additions & 6 deletions src/utils/swagger/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading