api generation impromenet

This commit is contained in:
kirillius 2026-01-21 14:43:18 +03:00
parent f882cf4c3a
commit 4032586662
36 changed files with 1451 additions and 223 deletions

3
.gitignore vendored
View File

@ -43,3 +43,6 @@ xcpdata.mv.db
/web-ui/api.spec.json
api-sandbox/app/node_modules
api-sandbox/app/api.spec.json
/xcpdata.trace.db
web-ui/vue-app/TODO.md
web-ui/vue-app/src/generated/*

View File

@ -11,7 +11,7 @@ async function loadUserProfile() {
params: {},
id: Date.now()
}
const response = await fetch('http://localhost:8080/api', {
method: 'POST',
headers: {
@ -20,7 +20,7 @@ async function loadUserProfile() {
credentials: 'include',
body: JSON.stringify(requestData)
})
const result = await response.json()
if (result.result) {
currentUser = result.result
@ -50,7 +50,9 @@ async function loadApiSpec() {
}
apiSpec = await response.json()
console.log('API Spec loaded:', apiSpec)
return apiSpec
let moduleSpecs = {};
apiSpec.modules.forEach(m => moduleSpecs[m.name] = m);
return moduleSpecs
} catch (error) {
console.error('Ошибка загрузки API спецификации:', error)
$('#result').text('Ошибка загрузки API спецификации: ' + error.message)
@ -94,13 +96,13 @@ function createParamInputs(methodName, serviceName) {
method.params.forEach(param => {
const required = !param.optional ? ' (обязательно)' : ' (необязательно)'
const isOptional = param.optional
const isObjectOrArray = param.type === 'object' || param.type === 'array'
const defaultValue = param.type === 'object' ? '{}' : (param.type === 'array' ? '[]' : '')
const inputElement = isObjectOrArray
const inputElement = isObjectOrArray
? `<textarea id="param-${param.name}" data-param="${param.name}" placeholder="${param.description}" rows="4" style="font-family: 'Courier New', monospace;">${defaultValue}</textarea>`
: `<input type="text" id="param-${param.name}" data-param="${param.name}" placeholder="${param.description}">`
if (isOptional) {
const $paramDiv = $(`
<div class="param">
@ -115,8 +117,8 @@ function createParamInputs(methodName, serviceName) {
</div>
`)
$paramsContainer.append($paramDiv)
$(`#defined-${param.name}`).on('change', function() {
$(`#defined-${param.name}`).on('change', function () {
const $input = $(`#param-${param.name}`)
const isChecked = $(this).is(':checked')
$input.prop('disabled', !isChecked)
@ -124,7 +126,7 @@ function createParamInputs(methodName, serviceName) {
$input.val(isObjectOrArray ? defaultValue : '')
}
})
// Initially disable optional fields
$(`#param-${param.name}`).prop('disabled', true)
} else {
@ -156,13 +158,13 @@ async function sendRequest() {
const $input = $(this).find('input[type="text"], textarea')
const paramName = $input.data('param')
const paramType = $input.attr('id').replace('param-', '')
// Find parameter type from method definition
const service = apiSpec[serviceName]
const method = service.methods.find(m => m.name === methodName)
const paramDef = method.params.find(p => p.name === paramName)
const isObjectOrArray = paramDef && (paramDef.type === 'object' || paramDef.type === 'array')
if ($checkbox.length > 0) {
// Optional parameter
if ($checkbox.is(':checked') && $input.val().trim() !== '') {
@ -223,10 +225,10 @@ async function sendRequest() {
const result = await response.json()
$('#result').text(JSON.stringify(result, null, 2))
$('#request').text(JSON.stringify(requestData, null, 2))
// Проверяем, нужно ли обновить профиль пользователя
if (result.result && (
methodName.startsWith('authenticateBy') ||
methodName.startsWith('authenticateBy') ||
methodName === 'logout'
)) {
await loadUserProfile()
@ -241,7 +243,7 @@ $(document).ready(async () => {
await loadApiSpec()
if (apiSpec) {
populateServices()
// Автоматически загружаем профиль пользователя
await loadUserProfile()

View File

@ -1,6 +1,7 @@
package ru.kirillius.XCP.ApiGenerator;
import lombok.SneakyThrows;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcMethod;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcService;
import tools.jackson.databind.JsonNode;
@ -9,9 +10,14 @@ import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
import java.io.*;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.regex.Pattern;
public class SpecGenerator {
@ -46,10 +52,14 @@ public class SpecGenerator {
}
private final ObjectNode specs = JsonNodeFactory.instance.objectNode();
private final Set<Class<?>> types = new HashSet<>();
private ObjectMapper mapper = new ObjectMapper();
@SneakyThrows
public void generate() {
specs.removeAll();
var modules = specs.putArray("modules");
var typesArray = specs.putArray("types");
var scanPath = new File(scanDir, packageName.replaceAll(Pattern.quote("."), "/"));
for (var file : Objects.requireNonNull(scanPath.listFiles())) {
if (!file.getName().endsWith(".java") || file.getName().contains("$")) {
@ -64,7 +74,7 @@ public class SpecGenerator {
continue;
}
var classSpecs = specs.putObject(cls.getSimpleName());
var classSpecs = modules.addObject();
classSpecs.put("name", cls.getSimpleName());
var methods = classSpecs.putArray("methods");
@ -78,7 +88,7 @@ public class SpecGenerator {
methodSpecs.put("description", descriptor.description());
methodSpecs.put("return", getTypeName(descriptor.returnType()));
methodSpecs.put("accessLevel", descriptor.accessLevel().name());
registerType(descriptor.returnType());
var params = methodSpecs.putArray("params");
for (var parameter : descriptor.parameters()) {
@ -87,14 +97,72 @@ public class SpecGenerator {
paramSpecs.put("type", getTypeName(parameter.type()));
paramSpecs.put("description", parameter.description());
paramSpecs.put("optional", parameter.optional());
registerType(parameter.type());
}
methodSpecs.put("return", getTypeName(descriptor.returnType()));
}
}
var typesQueue = new LinkedBlockingQueue<>(types);
Class<?> type;
while (!typesQueue.isEmpty()) {
type = typesQueue.poll();
if (type.isEnum()) {
var typeDescriptor = typesArray.addObject();
typeDescriptor.put("name", type.getSimpleName());
typeDescriptor.put("type", "enum");
var values = typeDescriptor.putArray("values");
for (var value : type.getEnumConstants()) {
values.add(value.toString());
}
continue;
}
var classAnnotation = type.getAnnotation(GenerateApiSpec.class);
if (classAnnotation == null || !type.isInterface()) {
continue;
}
var typeDescriptor = typesArray.addObject();
typeDescriptor.put("name", classAnnotation.alias().isEmpty() ? type.getSimpleName() : classAnnotation.alias());
typeDescriptor.put("type", "class");
var fields = typeDescriptor.putArray("fields");
for (var method : type.getMethods()) {
var methodAnnotation = method.getAnnotation(GenerateApiSpec.class);
if (methodAnnotation == null) {
continue;
}
var returnType = methodAnnotation.type();
if (returnType == void.class) {
returnType = method.getReturnType();
}
if (!types.contains(returnType)) {
types.add(returnType);
typesQueue.put(returnType);
}
var methodName = method.getName();
if (!methodName.startsWith("get") && !methodName.startsWith("is")) {
throw new RuntimeException("Invalid @" + GenerateApiSpec.class.getSimpleName() + " usage on " + type.getSimpleName() + "::" + methodName + ": Only getters are supported");
}
var fieldDescriptor = fields.addObject();
fieldDescriptor.put("name", methodAnnotation.alias().isEmpty() ? getVariableName(methodName) : methodAnnotation.alias());
fieldDescriptor.put("type", getTypeName(returnType));
}
}
}
private String getVariableName(String methodName) {
if (methodName.startsWith("get")) {
return methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
} else if (methodName.startsWith("is")) {
return methodName.substring(2, 3).toLowerCase() + methodName.substring(3);
}
throw new RuntimeException("Invalid method name: " + methodName);
}
public void writeSpecs() throws IOException {
@ -108,14 +176,31 @@ public class SpecGenerator {
}
}
private static String getTypeName(Class<?> type) {
private void registerType(Class<?> type) {
if (type.isArray()) {
types.add(type.getComponentType());
} else {
types.add(type);
}
}
private String getTypeName(Class<?> type) {
if (type == null) return "unknown";
if (type == boolean.class) return "boolean";
if (type == int.class || type == long.class) return "number";
if (type == String.class) return "string";
if (type == void.class || type == Void.class) return "void";
if (type.isArray() || Collection.class.isAssignableFrom(type) || type == ArrayNode.class) {
if (type.isArray()) {
var componentType = type.getComponentType();
if (componentType != null) {
return componentType.getSimpleName() + "[]";
} else {
return "[]";
}
}
if (Collection.class.isAssignableFrom(type) || type == ArrayNode.class) {
return "array";
}

View File

@ -1,140 +1,243 @@
{
"Profile" : {
"name" : "Profile",
"methods" : [ {
"name" : "get",
"description" : "Get current user profile",
"return" : "void",
"accessLevel" : "Guest",
"params" : [ ]
}, {
"name" : "save",
"description" : "edit current user profile",
"return" : "boolean",
"accessLevel" : "User",
"params" : [ {
"name" : "login",
"type" : "string",
"description" : "User login. Have to be unique",
"optional" : false
}, {
"name" : "name",
"type" : "string",
"description" : "User display name",
"optional" : false
}, {
"name" : "password",
"type" : "string",
"description" : "Change user password if defined",
"optional" : true
}, {
"name" : "values",
"type" : "object",
"description" : "User custom values",
"optional" : false
} ]
} ]
},
"UserManagement" : {
"name" : "UserManagement",
"methods" : [ {
"name" : "getById",
"description" : "Load user object by ID",
"return" : "object",
"accessLevel" : "Admin",
"params" : [ {
"name" : "id",
"type" : "number",
"description" : "User id",
"optional" : false
} ]
}, {
"name" : "remove",
"description" : "Remove user by ID",
"return" : "object",
"accessLevel" : "Admin",
"params" : [ {
"name" : "id",
"type" : "number",
"description" : "User id",
"optional" : false
} ]
}, {
"name" : "save",
"description" : "Get all users as list",
"return" : "array",
"accessLevel" : "Admin",
"params" : [ {
"name" : "user",
"type" : "object",
"description" : "User object to save",
"optional" : false
} ]
}, {
"name" : "getAll",
"description" : "Get all users as list",
"return" : "array",
"accessLevel" : "Admin",
"params" : [ ]
} ]
},
"Auth" : {
"name" : "Auth",
"methods" : [ {
"name" : "authenticateByPassword",
"description" : "Authenticates a user using login and password. Returns true if authentication is successful.",
"return" : "boolean",
"accessLevel" : "Guest",
"params" : [ {
"name" : "login",
"type" : "string",
"description" : "User's login name",
"optional" : false
}, {
"name" : "password",
"type" : "string",
"description" : "User's password",
"optional" : false
} ]
}, {
"name" : "getTokens",
"description" : "Retrieves all API tokens associated with the current user",
"return" : "array",
"accessLevel" : "User",
"params" : [ ]
}, {
"name" : "generateToken",
"description" : "Generates a new API token and returns its details",
"return" : "object",
"accessLevel" : "User",
"params" : [ {
"name" : "permanent",
"type" : "boolean",
"description" : "If true, creates a token that never expires",
"optional" : true
}, {
"name" : "name",
"type" : "string",
"description" : "Display name for the token. If not provided, the User-Agent header will be used",
"optional" : true
} ]
}, {
"name" : "authenticateByToken",
"description" : "Authenticates a user using an API token. Returns true if authentication is successful.",
"return" : "boolean",
"accessLevel" : "Guest",
"params" : [ {
"name" : "token",
"type" : "string",
"description" : "API token string for authentication",
"optional" : false
} ]
}, {
"name" : "logout",
"description" : "Logs out the current user. Returns true if logout is successful, false if the user is not logged in",
"return" : "boolean",
"accessLevel" : "User",
"params" : [ ]
} ]
}
"modules": [
{
"name": "Profile",
"methods": [
{
"name": "get",
"description": "Get current user profile",
"return": "User",
"accessLevel": "Guest",
"params": []
},
{
"name": "save",
"description": "edit current user profile",
"return": "boolean",
"accessLevel": "User",
"params": [
{
"name": "login",
"type": "string",
"description": "User login. Have to be unique",
"optional": false
},
{
"name": "name",
"type": "string",
"description": "User display name",
"optional": false
},
{
"name": "password",
"type": "string",
"description": "Change user password if defined",
"optional": true
},
{
"name": "values",
"type": "object",
"description": "User custom values",
"optional": false
}
]
}
]
},
{
"name": "Auth",
"methods": [
{
"name": "generateToken",
"description": "Generates a new API token and returns its details",
"return": "ApiToken",
"accessLevel": "User",
"params": [
{
"name": "permanent",
"type": "boolean",
"description": "If true, creates a token that never expires",
"optional": true
},
{
"name": "name",
"type": "string",
"description": "Display name for the token. If not provided, the User-Agent header will be used",
"optional": true
}
]
},
{
"name": "authenticateByToken",
"description": "Authenticates a user using an API token. Returns true if authentication is successful.",
"return": "boolean",
"accessLevel": "Guest",
"params": [
{
"name": "token",
"type": "string",
"description": "API token string for authentication",
"optional": false
}
]
},
{
"name": "logout",
"description": "Logs out the current user. Returns true if logout is successful, false if the user is not logged in",
"return": "boolean",
"accessLevel": "User",
"params": []
},
{
"name": "authenticateByPassword",
"description": "Authenticates a user using login and password. Returns true if authentication is successful.",
"return": "boolean",
"accessLevel": "Guest",
"params": [
{
"name": "login",
"type": "string",
"description": "User's login name",
"optional": false
},
{
"name": "password",
"type": "string",
"description": "User's password",
"optional": false
}
]
},
{
"name": "getTokens",
"description": "Retrieves all API tokens associated with the current user",
"return": "ApiToken[]",
"accessLevel": "User",
"params": []
}
]
},
{
"name": "Users",
"methods": [
{
"name": "getById",
"description": "Load user object by ID",
"return": "User",
"accessLevel": "Admin",
"params": [
{
"name": "id",
"type": "number",
"description": "User id",
"optional": false
}
]
},
{
"name": "remove",
"description": "Remove user by ID",
"return": "boolean",
"accessLevel": "Admin",
"params": [
{
"name": "id",
"type": "number",
"description": "User id",
"optional": false
}
]
},
{
"name": "save",
"description": "Save user data",
"return": "User",
"accessLevel": "Admin",
"params": [
{
"name": "user",
"type": "User",
"description": "User object to save",
"optional": false
}
]
},
{
"name": "getAll",
"description": "Get all users as list",
"return": "User[]",
"accessLevel": "Admin",
"params": []
}
]
}
],
"types": [
{
"name": "ApiToken",
"type": "class",
"fields": [
{
"name": "expirationDate",
"type": "Date"
},
{
"name": "user",
"type": "PersistenceEntity"
},
{
"name": "name",
"type": "string"
},
{
"name": "uuid",
"type": "UUID"
},
{
"name": "id",
"type": "number"
}
]
},
{
"name": "User",
"type": "class",
"fields": [
{
"name": "login",
"type": "string"
},
{
"name": "name",
"type": "string"
},
{
"name": "values",
"type": "object"
},
{
"name": "role",
"type": "UserRole"
},
{
"name": "uuid",
"type": "UUID"
},
{
"name": "id",
"type": "number"
}
]
},
{
"name": "UserRole",
"type": "enum",
"values": [
"Guest",
"User",
"Operator",
"Admin"
]
}
]
}

View File

@ -0,0 +1,14 @@
package ru.kirillius.XCP.Commons;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface GenerateApiSpec {
String alias() default "";
Class<?> type() default void.class;
}

View File

@ -1,21 +1,26 @@
package ru.kirillius.XCP.Persistence.Entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.PersistenceEntity;
import java.time.Instant;
import java.util.Date;
@GenerateApiSpec
public interface ApiToken extends PersistenceEntity {
@GenerateApiSpec(type = PersistenceEntity.class)
User getUser();
void setUser(User user);
@GenerateApiSpec
String getName();
void setName(String name);
@GenerateApiSpec
Date getExpirationDate();
void setExpirationDate(Date expirationDate);

View File

@ -1,12 +1,15 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.NodeEntity;
@GenerateApiSpec
public interface Group extends NodeEntity {
String getIcon();
void setIcon(String icon);
@GenerateApiSpec
boolean isPrototype();
void setPrototype(boolean prototype);

View File

@ -1,11 +1,13 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Data.PollSettings;
import ru.kirillius.XCP.Persistence.IOEntity;
import ru.kirillius.XCP.Persistence.NodeEntity;
@GenerateApiSpec
public interface Input extends IOEntity, NodeEntity {
@GenerateApiSpec
PollSettings getPollSettings();
void setPollSettings(PollSettings pollSettings);

View File

@ -1,7 +1,8 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.IOEntity;
import ru.kirillius.XCP.Persistence.NodeEntity;
@GenerateApiSpec
public interface Output extends IOEntity, NodeEntity {
}

View File

@ -1,8 +1,11 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.PersistenceEntity;
@GenerateApiSpec
public interface Tag extends PersistenceEntity {
@GenerateApiSpec
String getName();
void setName(String name);

View File

@ -1,26 +1,32 @@
package ru.kirillius.XCP.Persistence.Entities;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.PersistenceEntity;
import ru.kirillius.XCP.Security.UserRole;
import tools.jackson.databind.node.ObjectNode;
@GenerateApiSpec
public interface User extends PersistenceEntity {
void setPassword(String password);
boolean verifyPassword(String password);
@GenerateApiSpec
String getLogin();
void setLogin(String login);
@GenerateApiSpec
UserRole getRole();
void setRole(UserRole role);
@GenerateApiSpec
ObjectNode getValues();
void setValues(ObjectNode values);
@GenerateApiSpec
String getName();
void setName(String name);

View File

@ -1,29 +1,36 @@
package ru.kirillius.XCP.Persistence;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.Entities.Group;
import tools.jackson.databind.node.ObjectNode;
public interface NodeEntity extends PersistenceEntity {
@GenerateApiSpec
String getName();
void setName(String name);
@GenerateApiSpec
boolean isProtectedEntity();
void setProtectedEntity(boolean essential);
@GenerateApiSpec
boolean isEnabled();
void setEnabled(boolean enabled);
@GenerateApiSpec(type = PersistenceEntity.class)
Group getParent();
void setParent(Group parent);
@GenerateApiSpec
ObjectNode getProperties();
void setProperties(ObjectNode properties);
@GenerateApiSpec
TagCollection getTags();
void setTags(TagCollection tags);

View File

@ -1,9 +1,13 @@
package ru.kirillius.XCP.Persistence;
import ru.kirillius.XCP.Commons.GenerateApiSpec;
import java.util.UUID;
public interface PersistenceEntity {
@GenerateApiSpec
long getId();
@GenerateApiSpec
UUID getUuid();
}

View File

@ -1,5 +1,6 @@
package ru.kirillius.XCP.RPC.Services;
import ru.kirillius.XCP.Persistence.Entities.ApiToken;
import ru.kirillius.XCP.Persistence.Repositories.ApiTokenRepository;
import ru.kirillius.XCP.Persistence.Repositories.UserRepository;
import ru.kirillius.XCP.Services.RepositoryService;
@ -56,11 +57,10 @@ public class Auth extends JsonRpcService {
return true;
}
@JsonRpcMethod(
accessLevel = UserRole.User,
description = "Retrieves all API tokens associated with the current user",
returnType = ArrayNode.class
returnType = ApiToken[].class
)
public ArrayNode getTokens(CallContext call) throws IOException {
var tokenRepository = call.getContext().getService(RepositoryService.class).getRepository(ApiTokenRepository.class);
@ -70,14 +70,13 @@ public class Auth extends JsonRpcService {
var json = tokenRepository.serialize(token);
json.remove("uuid");
var uuid = token.getUuid().toString();
json.put("token", uuid.substring(0, 4) + "..." + uuid.substring(uuid.length() - 4));
json.put("uuid", uuid.substring(0, 4) + "..." + uuid.substring(uuid.length() - 4));
return json;
}).forEach(tokens::add);
}
return tokens;
}
@JsonRpcMethod(
accessLevel = UserRole.User,
description = "Generates a new API token and returns its details",
@ -95,7 +94,7 @@ public class Auth extends JsonRpcService {
optional = true
)
},
returnType = ObjectNode.class
returnType = ApiToken.class
)
public ObjectNode generateToken(CallContext call) {
var repositoryService = call.getContext().getService(RepositoryService.class);

View File

@ -1,16 +1,16 @@
package ru.kirillius.XCP.RPC.Services;
import ru.kirillius.XCP.Persistence.Entities.User;
import ru.kirillius.XCP.Persistence.Repositories.UserRepository;
import ru.kirillius.XCP.Services.RepositoryService;
import ru.kirillius.XCP.RPC.JSONRPC.CallContext;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcMethod;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcService;
import ru.kirillius.XCP.Security.UserRole;
import ru.kirillius.XCP.Services.RepositoryService;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ObjectNode;
public class Profile extends JsonRpcService {
@JsonRpcMethod(
accessLevel = UserRole.User,
description = "edit current user profile",
@ -60,7 +60,8 @@ public class Profile extends JsonRpcService {
@JsonRpcMethod(
accessLevel = UserRole.Guest,
description = "Get current user profile"
description = "Get current user profile",
returnType = User.class
)
public ObjectNode get(CallContext call) {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);

View File

@ -1,25 +1,26 @@
package ru.kirillius.XCP.RPC.Services;
import ru.kirillius.XCP.Persistence.Entities.User;
import ru.kirillius.XCP.Persistence.Repositories.UserRepository;
import ru.kirillius.XCP.Services.RepositoryService;
import ru.kirillius.XCP.RPC.JSONRPC.CallContext;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcMethod;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcService;
import ru.kirillius.XCP.Security.UserRole;
import ru.kirillius.XCP.Services.RepositoryService;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
import java.io.IOException;
public class UserManagement extends JsonRpcService {
public class Users extends JsonRpcService {
@JsonRpcMethod(
accessLevel = UserRole.Admin,
description = "Get all users as list",
parameters = {
},
returnType = ArrayNode.class
returnType = User[].class
)
public ArrayNode getAll(CallContext call) throws IOException {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
@ -30,11 +31,11 @@ public class UserManagement extends JsonRpcService {
@JsonRpcMethod(
accessLevel = UserRole.Admin,
description = "Get all users as list",
description = "Save user data",
parameters = {
@JsonRpcMethod.Parameter(name = "user", description = "User object to save", type = ObjectNode.class)
@JsonRpcMethod.Parameter(name = "user", description = "User object to save", type = User.class)
},
returnType = ArrayNode.class
returnType = User.class
)
public void save(CallContext call) {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
@ -48,7 +49,7 @@ public class UserManagement extends JsonRpcService {
parameters = {
@JsonRpcMethod.Parameter(name = "id", description = "User id", type = int.class)
},
returnType = ObjectNode.class
returnType = User.class
)
public ObjectNode getById(CallContext call) {
var userId = requireParam(call, "id", JsonNode::asLong);
@ -62,7 +63,7 @@ public class UserManagement extends JsonRpcService {
parameters = {
@JsonRpcMethod.Parameter(name = "id", description = "User id", type = int.class)
},
returnType = ObjectNode.class
returnType = boolean.class
)
public boolean remove(CallContext call) {
var userId = requireParam(call, "id", JsonNode::asLong);

View File

@ -9,7 +9,7 @@ import ru.kirillius.XCP.Commons.Context;
import ru.kirillius.XCP.RPC.JSONRPC.JsonRpcServlet;
import ru.kirillius.XCP.RPC.Services.Auth;
import ru.kirillius.XCP.RPC.Services.Profile;
import ru.kirillius.XCP.RPC.Services.UserManagement;
import ru.kirillius.XCP.RPC.Services.Users;
import ru.kirillius.XCP.Services.ServiceLoadPriority;
import ru.kirillius.XCP.Services.WebService;
@ -28,7 +28,7 @@ public class WebServiceImpl implements WebService {
}
var jsonRpc = new JsonRpcServlet(context);
jsonRpc.registerRpcService(
UserManagement.class,
Users.class,
Auth.class,
Profile.class
);

View File

@ -25,4 +25,5 @@ coverage
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?
/src/generated/RpcClient.ts

View File

@ -0,0 +1,38 @@
import pluginVue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
export default [
{
ignores: ['dist/**/*', 'node_modules/**/*']
},
{
files: ['src/**/*.{vue}'],
languageOptions: {
parser: pluginVue.parser,
parserOptions: {
parser: typescriptParser,
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: {
vue: pluginVue
},
rules: pluginVue.configs['vue3-essential'].rules
},
{
files: ['src/**/*.{js,mjs,cjs,ts}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: {
'@typescript-eslint': typescript
},
rules: typescript.configs.recommended.rules
}
]

View File

@ -8,9 +8,11 @@
"name": "vue-app",
"version": "0.0.0",
"dependencies": {
"@mdi/font": "^7.4.47",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vuetify": "^3.11.6"
},
"devDependencies": {
"@cypress/vite-dev-server": "^7.1.0",
@ -19,6 +21,8 @@
"@tsconfig/node18": "^18.2.4",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
@ -26,9 +30,12 @@
"@vue/tsconfig": "^0.8.1",
"cypress": "^15.9.0",
"eslint": "^9.39.2",
"eslint-define-config": "^2.1.0",
"eslint-plugin-vue": "^9.32.0",
"jsdom": "^27.4.0",
"prettier": "^3.7.4",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.17",
@ -123,6 +130,19 @@
"node": ">=6.9.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@ -1108,12 +1128,39 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@mdi/font": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
"integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==",
"license": "Apache-2.0"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1554,6 +1601,34 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node18": {
"version": "18.2.6",
"resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.6.tgz",
@ -2370,6 +2445,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -2504,6 +2592,13 @@
],
"license": "MIT"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -3009,6 +3104,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3237,6 +3339,16 @@
"node": ">=0.4.0"
}
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@ -3602,6 +3714,29 @@
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-define-config": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/eslint-define-config/-/eslint-define-config-2.1.0.tgz",
"integrity": "sha512-QUp6pM9pjKEVannNAbSJNeRuYwW3LshejfyBBpjeMGaJjaDUpVps4C6KVR8R7dWZnD3i0synmrE36znjTkJvdQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/Shinigami92"
},
{
"type": "paypal",
"url": "https://www.paypal.com/donate/?hosted_button_id=L7GY729FBKTZY"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0",
"pnpm": ">=8.6.0"
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
@ -4400,6 +4535,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
@ -5268,6 +5416,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -5986,6 +6141,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@ -6679,6 +6844,50 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -6686,6 +6895,26 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@ -6821,6 +7050,13 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
@ -7143,6 +7379,33 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vuetify": {
"version": "3.11.6",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.6.tgz",
"integrity": "sha512-vaWvEpDSeldRoib1tCKNVBp60paTD2/n0a0TmrVLF9BN4CJJn6/A4VKG2Sg+DE8Yc+SNOtFtipChxSlQxjcUvw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/johnleider"
},
"peerDependencies": {
"typescript": ">=4.7",
"vite-plugin-vuetify": ">=2.1.0",
"vue": "^3.5.0",
"webpack-plugin-vuetify": ">=3.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"vite-plugin-vuetify": {
"optional": true
},
"webpack-plugin-vuetify": {
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@ -7404,6 +7667,16 @@
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",

View File

@ -4,18 +4,22 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"dev": "npm run generate-client && vite",
"build": "npm run generate-client && vue-tsc && vite build",
"build:prod": "npm run generate-client && vue-tsc && vite build",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "cypress run",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
"format": "prettier --write src/",
"generate-client": "npx tsx scripts/generate-rpc-client.ts"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vuetify": "^3.11.6"
},
"devDependencies": {
"@cypress/vite-dev-server": "^7.1.0",
@ -24,6 +28,8 @@
"@tsconfig/node18": "^18.2.4",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
@ -31,9 +37,12 @@
"@vue/tsconfig": "^0.8.1",
"cypress": "^15.9.0",
"eslint": "^9.39.2",
"eslint-define-config": "^2.1.0",
"eslint-plugin-vue": "^9.32.0",
"jsdom": "^27.4.0",
"prettier": "^3.7.4",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.17",

View File

@ -0,0 +1,140 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
interface ApiMethodParam {
name: string
type: string
description: string
optional: boolean
}
interface ApiMethod {
name: string
description: string
return: string
accessLevel: string
params: ApiMethodParam[]
}
interface ApiModule {
name: string
methods: ApiMethod[]
}
interface ApiSpec {
modules: ApiModule[]
types: any[]
}
function generateMethod(method: ApiMethod): string {
const sortedParams = [...method.params].sort((a, b) => {
if (a.optional && !b.optional) return 1
if (!a.optional && b.optional) return -1
return 0
})
const params = sortedParams
.map((param) => {
const optional = param.optional ? '?' : ''
return `${param.name}${optional}: ${param.type}`
})
.join(', ')
const callParams = method.params.map((param) => `${param.name}: ${param.name}`).join(', ')
return `
/**
* @description ${method.description}
${sortedParams.map((param) => ` * @param ${param.name} ${param.description}`).join('\n')}
* @return ${method.return}
*/
async ${method.name}(${params}): Promise<${method.return}> {
return this.call("${method.name}"${callParams ? ', {' + callParams + '}' : ''});
}`
}
function generateModule(module: ApiModule): string {
const className = `${module.name}Module`
const methods = module.methods.map(generateMethod).join('\n')
return `export class ${className} extends RpcModuleBase {
constructor(client: RpcClientBase) {
super(client, "${module.name}");
}${methods}
}`
}
function generateClient(spec: ApiSpec): string {
const modules = spec.modules
.map((module) => {
const moduleName = module.name
const className = `${module.name}Module`
return ` public ${moduleName}: ${className};`
})
.join('\n')
const initializations = spec.modules
.map((module) => {
const moduleName = module.name
const className = `${module.name}Module`
return ` this.${moduleName} = new ${className}(this);`
})
.join('\n')
const moduleClasses = spec.modules.map(generateModule).join('\n\n')
return `import {RpcClientBase} from "@/api/RpcClientBase.ts";
import {RpcModuleBase} from "@/api/RpcModuleBase.ts";
export class RpcClient extends RpcClientBase {${modules}
constructor(baseUrl: string) {
super(baseUrl);
${initializations}
}
}
${moduleClasses}`
}
function main() {
const specPath = path.resolve(__dirname, '../../../api.spec.json')
const outputPath = path.resolve(__dirname, '../src/generated/RpcClient.ts')
console.log('🚀 Generating RPC client...')
try {
if (!fs.existsSync(specPath)) {
throw new Error(`API spec file not found: ${specPath}`)
}
const specContent = fs.readFileSync(specPath, 'utf-8')
const spec: ApiSpec = JSON.parse(specContent)
const clientCode = generateClient(spec)
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
fs.writeFileSync(outputPath, clientCode)
console.log(`✅ RPC client generated successfully: ${outputPath}`)
console.log(`📋 Generated ${spec.modules.length} API modules`)
} catch (error) {
console.error('❌ Error generating RPC client:', error)
process.exit(1)
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main()
}
export { main as generateClient }

View File

@ -1,21 +1,80 @@
<template>
<div id="app">
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
<RouterView />
</div>
<v-app>
<FooterBar @toggle-menu="toggleMenu" />
<v-main
:class="{
'mobile-main': isMobile,
'collapsed-menu': !isMobile && isCollapsed
}"
>
<RouterView />
</v-main>
<NavigationMenu ref="navigationMenu" @update-collapsed="isCollapsed = $event" />
</v-app>
</template>
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterView } from 'vue-router'
import NavigationMenu from './components/NavigationMenu.vue'
import FooterBar from './components/FooterBar.vue'
const navigationMenu = ref()
const windowWidth = ref(window.innerWidth)
const isCollapsed = ref(false)
const isMobile = computed(() => windowWidth.value < 768)
const toggleMenu = () => {
if (navigationMenu.value) {
navigationMenu.value.toggle()
}
}
const updateWidth = () => {
windowWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', updateWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateWidth)
})
</script>
<style scoped>
nav {
padding: 1rem;
display: flex;
gap: 1rem;
<style>
#app {
height: 100vh;
overflow: hidden;
}
</style>
.v-application {
height: 100vh;
}
.mobile-main {
padding-top: 64px !important;
height: calc(100vh - 64px) !important;
}
.mobile-main :deep(.v-main__content) {
padding: 0 !important;
}
.mobile-main :deep(.v-container) {
padding: 0 !important;
}
/* Исправляем контент для ПК */
.v-main:not(.mobile-main) {
max-width: calc(100% - 250px) !important;
}
.v-main:not(.mobile-main).collapsed-menu {
max-width: calc(100% - 80px) !important;
}
</style>

View File

@ -0,0 +1,33 @@
export class RpcClientBase {
constructor(private baseUrl: string) {
}
public async callModuleMethod<T>(module: string, methodName: string, params?: any):Promise<T> {
return this.call(module + "." + methodName, params);
}
protected async call<T>(method: string, params?: any): Promise<T> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method,
params,
id: Date.now()
})
});
if (!response.ok) {
throw new Error(`RPC call ${method} failed: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`RPC error: ${data.error.message}`);
}
return data.result;
}
}

View File

@ -0,0 +1,10 @@
import type {RpcClientBase} from "@/api/RpcClientBase.ts";
export class RpcModuleBase {
constructor(private client: RpcClientBase, private moduleName: string) {
}
protected async call<T>(method: string, params?: any): Promise<T> {
return this.client.callModuleMethod(this.moduleName, method, params);
}
}

View File

@ -0,0 +1,67 @@
<template>
<v-app-bar v-if="isMobile" app color="primary" class="mobile-header-bar" elevation="4">
<v-container fluid class="pa-0">
<v-row align="center" no-gutters>
<v-col cols="8" class="d-flex align-center">
<v-icon :icon="currentPageInfo.icon" color="white" class="me-2" />
<span class="current-page-text">{{ currentPageInfo.title }}</span>
</v-col>
<v-col cols="4" class="text-end">
<v-btn icon="mdi-menu" variant="text" @click="toggleMenu" color="white" />
</v-col>
</v-row>
</v-container>
</v-app-bar>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const isMobile = ref(window.innerWidth < 768)
const currentPageInfo = computed(() => {
const routeMap: Record<string, { title: string; icon: string }> = {
'/': { title: 'Главная', icon: 'mdi-home' },
'/profile': { title: 'Профиль', icon: 'mdi-account' },
'/settings': { title: 'Настройки', icon: 'mdi-cog' },
'/dashboard': { title: 'Панель управления', icon: 'mdi-view-dashboard' }
}
return routeMap[route.path] || { title: 'Неизвестная страница', icon: 'mdi-help' }
})
const emit = defineEmits<{
toggleMenu: []
}>()
const toggleMenu = () => {
emit('toggleMenu')
}
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.footer-bar {
z-index: 1001;
}
.current-page-text {
color: white;
font-weight: 500;
font-size: 1rem;
}
</style>

View File

@ -0,0 +1,214 @@
<template>
<!-- Мобильное меню - как Android шторка с плитками -->
<v-navigation-drawer
v-if="isMobile"
v-model="menuOpen"
temporary
location="top"
class="mobile-menu-drawer"
>
<v-container fluid class="pa-4">
<v-row dense>
<v-col v-for="item in menuItems" :key="item.path" cols="4" class="d-flex justify-center">
<v-card
class="menu-tile text-center"
:color="currentPath === item.path ? 'primary' : 'surface-variant'"
elevation="2"
@click="navigateTo(item.path)"
width="100%"
>
<v-card-text class="pa-3">
<v-icon
size="32"
:icon="item.icon"
:color="currentPath === item.path ? 'white' : '#757575'"
/>
<div
class="text-caption mt-2 font-weight-medium"
:class="currentPath === item.path ? 'text-white' : ''"
:style="currentPath === item.path ? {} : { color: '#757575' }"
>
{{ item.title }}
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-navigation-drawer>
<!-- Десктопное меню - боковая панель -->
<v-navigation-drawer
v-if="!isMobile"
permanent
location="left"
class="desktop-menu-drawer"
:width="collapsed ? 80 : 250"
:rail="collapsed"
>
<div class="menu-header">
<v-btn
v-if="!collapsed"
icon="mdi-chevron-left"
variant="text"
@click="toggleCollapsed"
class="collapse-btn"
size="small"
/>
<v-btn
v-else
icon="mdi-chevron-right"
variant="text"
@click="toggleCollapsed"
class="collapse-btn"
size="small"
/>
</div>
<v-list v-model="selectedItem" class="menu-list">
<v-list-item
v-for="item in menuItems"
:key="item.path"
:prepend-icon="collapsed ? item.icon : undefined"
:title="collapsed ? '' : item.title"
:value="item.path"
@click="navigateTo(item.path)"
class="menu-item"
>
<template v-if="!collapsed" #prepend>
<v-icon :icon="item.icon" />
</template>
</v-list-item>
</v-list>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
interface MenuItem {
title: string
icon: string
path: string
}
const menuItems: MenuItem[] = [
{ title: 'Профиль', icon: 'mdi-account', path: '/profile' },
{ title: 'Настройки', icon: 'mdi-cog', path: '/settings' },
{ title: 'Панель управления', icon: 'mdi-view-dashboard', path: '/dashboard' }
]
const router = useRouter()
const route = useRoute()
const menuOpen = ref(false)
const collapsed = ref(false)
const isMobile = ref(window.innerWidth < 768)
const currentPath = computed(() => route.path)
const selectedItem = computed(() => currentPath.value)
const emit = defineEmits<{
updateCollapsed: [value: boolean]
}>()
const navigateTo = (path: string) => {
router.push(path)
if (isMobile.value) {
menuOpen.value = false
}
}
const toggleCollapsed = () => {
collapsed.value = !collapsed.value
emit('updateCollapsed', collapsed.value)
}
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) {
menuOpen.value = false
}
}
onMounted(() => {
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
defineExpose({
toggle: () => {
menuOpen.value = !menuOpen.value
}
})
</script>
<style scoped>
.mobile-menu-drawer {
z-index: 1001;
height: auto !important;
max-height: 50vh;
border-radius: 0 0 16px 16px;
}
.desktop-menu-drawer {
z-index: 1000;
}
.menu-tile {
cursor: pointer;
transition: all 0.2s ease;
aspect-ratio: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.menu-tile:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.menu-tile:active {
transform: translateY(-2px);
}
.menu-header {
padding: 16px 8px 8px 8px;
display: flex;
justify-content: flex-end;
}
.collapse-btn {
z-index: 10;
}
.menu-list {
padding: 8px 4px;
}
.menu-item {
margin: 2px 4px;
border-radius: 6px;
}
.menu-item :deep(.v-list-item__content) {
padding-left: 4px;
}
.menu-item :deep(.v-list-item-title) {
text-align: left;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.mobile-menu-drawer {
width: 100% !important;
}
}
</style>

View File

@ -2,6 +2,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
}

View File

@ -1,14 +1,44 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import '@mdi/font/css/materialdesignicons.css'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'dark',
themes: {
dark: {
colors: {
background: '#121212',
surface: '#1e1e1e',
primary: '#2196F3',
'primary-darken-1': '#1976D2',
secondary: '#424242',
'secondary-darken-1': '#616161',
error: '#FF5252',
info: '#2196F3',
success: '#4CAF50',
warning: '#FB8C00'
}
}
}
}
})
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)
app.mount('#app')
app.mount('#app')

View File

@ -1,20 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import ProfileView from '../views/ProfileView.vue'
import SettingsView from '../views/SettingsView.vue'
import DashboardView from '../views/DashboardView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
redirect: '/profile'
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue')
path: '/profile',
name: 'profile',
component: ProfileView
},
{
path: '/settings',
name: 'settings',
component: SettingsView
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardView
}
]
})
export default router
export default router

View File

@ -0,0 +1,29 @@
<template>
<div class="mobile-full-height">
<div class="d-flex justify-center align-center fill-height">
<div class="text-center">
<v-icon size="64" color="primary" class="mb-4"> mdi-view-dashboard </v-icon>
<h1 class="text-h4 mb-2">Панель управления</h1>
<p class="text-body-1 text-medium-emphasis">Панель управления и мониторинга</p>
</div>
</div>
</div>
</template>
<style scoped>
.mobile-full-height {
height: 100%;
width: 100%;
padding: 16px;
}
@media (max-width: 768px) {
.mobile-full-height {
padding: 0;
}
}
</style>
<script setup lang="ts">
// Панель управления
</script>

View File

@ -0,0 +1,29 @@
<template>
<div class="mobile-full-height">
<div class="d-flex justify-center align-center fill-height">
<div class="text-center">
<v-icon size="64" color="primary" class="mb-4"> mdi-account </v-icon>
<h1 class="text-h4 mb-2">Профиль</h1>
<p class="text-body-1 text-medium-emphasis">Страница профиля пользователя</p>
</div>
</div>
</div>
</template>
<style scoped>
.mobile-full-height {
height: 100%;
width: 100%;
padding: 16px;
}
@media (max-width: 768px) {
.mobile-full-height {
padding: 0;
}
}
</style>
<script setup lang="ts">
// Профиль пользователя
</script>

View File

@ -0,0 +1,29 @@
<template>
<div class="mobile-full-height">
<div class="d-flex justify-center align-center fill-height">
<div class="text-center">
<v-icon size="64" color="primary" class="mb-4"> mdi-cog </v-icon>
<h1 class="text-h4 mb-2">Настройки</h1>
<p class="text-body-1 text-medium-emphasis">Страница настроек приложения</p>
</div>
</div>
</div>
</template>
<style scoped>
.mobile-full-height {
height: 100%;
width: 100%;
padding: 16px;
}
@media (max-width: 768px) {
.mobile-full-height {
padding: 0;
}
}
</style>
<script setup lang="ts">
// Настройки приложения
</script>

View File

@ -1,14 +1,12 @@
import { defineConfig } from 'tsbuild'
export default defineConfig({
extends: '@vue/tsconfig/tsconfig.dom.json',
include: ['env.d.ts', 'src/**/*', 'src/**/*.vue'],
exclude: ['src/**/__tests__/*'],
compilerOptions: {
composite: true,
baseUrl: '.',
paths: {
'@/*': ['./src/*']
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
})
}

File diff suppressed because one or more lines are too long

View File

@ -1,12 +1,30 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { execSync } from 'child_process'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
{
name: 'generate-rpc-client',
buildStart() {
console.log('🔄 Generating RPC client before build...')
try {
execSync('npx tsx scripts/generate-rpc-client.ts', {
stdio: 'inherit',
cwd: process.cwd()
})
} catch (error) {
console.error('❌ Failed to generate RPC client:', error)
throw error
}
}
}
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
})