Правки сериализации, persistence и api-sandbox

This commit is contained in:
kirillius 2026-01-19 13:09:24 +03:00
parent 9e5b70161d
commit f882cf4c3a
23 changed files with 666 additions and 369 deletions

View File

@ -5,36 +5,119 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Debug Console</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
body {
font-family: 'Courier New', monospace;
margin: 0;
background-color: #1a1a1a;
color: #ffffff;
height: 100vh;
overflow: hidden;
}
.container {
display: flex;
height: 100vh;
}
.left-panel {
flex: 1;
padding: 20px;
border-right: 1px solid #ffffff;
overflow-y: auto;
}
.right-panel {
flex: 1;
padding: 20px;
overflow-y: auto;
}
h1 { color: #ffffff; border-bottom: 1px solid #ffffff; padding-bottom: 10px; margin-top: 0; }
.form-group { margin: 10px 0; }
label { display: block; margin-bottom: 5px; }
select, input { padding: 8px; width: 100%; max-width: 300px; }
button { padding: 10px 20px; background: #007bff; color: white; border: none; cursor: pointer; }
button:hover { background: #0056b3; }
#result { margin-top: 20px; white-space: pre-wrap; background: #f8f9fa; padding: 10px; border-radius: 4px; }
label { display: block; margin-bottom: 5px; color: #ffffff; }
select, input, textarea {
padding: 8px;
width: 100%;
max-width: 100%;
background: transparent;
border: 1px solid #ffffff;
color: #ffffff;
outline: none;
box-sizing: border-box;
}
select option { background: #1a1a1a; color: #ffffff; }
button {
padding: 10px 20px;
background: transparent;
color: #ffffff;
border: 1px solid #ffffff;
cursor: pointer;
outline: none;
margin-top: 10px;
}
button:hover {
background: #ffffff;
color: #1a1a1a;
}
#result, #request {
margin-top: 20px;
white-space: pre-wrap;
background: transparent;
padding: 10px;
font-family: 'Courier New', monospace;
overflow-y: auto;
}
.params { margin-top: 10px; }
.param { margin-bottom: 10px; }
.param { margin-bottom: 15px; }
.param-header { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; }
.checkbox-wrapper { display: flex; align-items: center; gap: 5px; }
.checkbox-wrapper input[type="checkbox"] { width: auto; margin: 0; accent-color: #ffffff; }
.checkbox-wrapper label { margin: 0; font-weight: normal; white-space: nowrap; color: #ffffff; }
input:disabled, textarea:disabled {
background: transparent;
color: #666;
border-color: #666;
}
input:focus, select:focus, textarea:focus {
border-color: #ffffff;
box-shadow: 0 0 2px #ffffff;
}
textarea {
resize: vertical;
min-height: 80px;
}
</style>
</head>
<body>
<div id="app">
<h1>API Debug Console</h1>
<div class="container">
<div class="left-panel">
<div id="app">
<h1>API Debug Console</h1>
<div id="user-info" class="form-group" style="margin-bottom: 20px;">
<label>Пользователь:</label>
<span id="username" style="color: #ccc;">не авторизован</span>
</div>
<div class="form-group">
<label for="service">Сервис:</label>
<select id="service"></select>
<div class="form-group">
<label for="service">Сервис:</label>
<select id="service"></select>
</div>
<div class="form-group">
<label for="method">Метод:</label>
<select id="method"></select>
</div>
<div id="params-container" class="params"></div>
<button id="send-btn">Отправить</button>
</div>
</div>
<div class="form-group">
<label for="method">Метод:</label>
<select id="method"></select>
<div class="right-panel">
<h1>Result</h1>
<div id="result"></div>
<h1 style="margin-top: 30px;">Request</h1>
<div id="request"></div>
</div>
<div id="params-container" class="params"></div>
<button id="send-btn">Отправить</button>
<div id="result"></div>
</div>
<script type="module" src="/main.js"></script>
</body>

View File

@ -1,134 +1,262 @@
import $ from 'jquery'
let apiSpec = null
let currentUser = null
async function loadUserProfile() {
try {
const requestData = {
jsonrpc: '2.0',
method: 'Profile.get',
params: {},
id: Date.now()
}
const response = await fetch('http://localhost:8080/api', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(requestData)
})
const result = await response.json()
if (result.result) {
currentUser = result.result
$('#username').text(currentUser.name || 'неизвестный пользователь')
} else {
currentUser = null
$('#username').text('не авторизован')
}
} catch (error) {
console.error('Ошибка загрузки профиля:', error)
currentUser = null
$('#username').text('не авторизован')
}
}
async function loadApiSpec() {
try {
const response = await fetch('/api.spec.json', {
method: 'GET',
headers: {
'Cache-Control': 'no-cache',
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
try {
const response = await fetch('/api.spec.json', {
method: 'GET',
headers: {
'Cache-Control': 'no-cache',
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
apiSpec = await response.json()
console.log('API Spec loaded:', apiSpec)
return apiSpec
} catch (error) {
console.error('Ошибка загрузки API спецификации:', error)
$('#result').text('Ошибка загрузки API спецификации: ' + error.message)
}
apiSpec = await response.json()
console.log('API Spec loaded:', apiSpec)
return apiSpec
} catch (error) {
console.error('Ошибка загрузки API спецификации:', error)
$('#result').text('Ошибка загрузки API спецификации: ' + error.message)
}
}
function populateServices() {
const $serviceSelect = $('#service')
$serviceSelect.empty()
$serviceSelect.append('<option value="">Выберите сервис</option>')
const $serviceSelect = $('#service')
$serviceSelect.empty()
$serviceSelect.append('<option value="">Выберите сервис</option>')
Object.keys(apiSpec).forEach(serviceName => {
$serviceSelect.append(`<option value="${serviceName}">${serviceName}</option>`)
})
Object.keys(apiSpec).forEach(serviceName => {
$serviceSelect.append(`<option value="${serviceName}">${serviceName}</option>`)
})
}
function populateMethods(serviceName) {
const $methodSelect = $('#method')
$methodSelect.empty()
$methodSelect.append('<option value="">Выберите метод</option>')
const $methodSelect = $('#method')
$methodSelect.empty()
$methodSelect.append('<option value="">Выберите метод</option>')
if (serviceName && apiSpec[serviceName] && apiSpec[serviceName].methods) {
apiSpec[serviceName].methods.forEach(method => {
$methodSelect.append(`<option value="${method.name}">${method.name}</option>`)
})
}
if (serviceName && apiSpec[serviceName] && apiSpec[serviceName].methods) {
apiSpec[serviceName].methods.forEach(method => {
$methodSelect.append(`<option value="${method.name}">${method.name}</option>`)
})
}
}
function createParamInputs(methodName, serviceName) {
const $paramsContainer = $('#params-container')
$paramsContainer.empty()
const $paramsContainer = $('#params-container')
$paramsContainer.empty()
if (!serviceName || !methodName) return
if (!serviceName || !methodName) return
const service = apiSpec[serviceName]
if (!service || !service.methods) return
const service = apiSpec[serviceName]
if (!service || !service.methods) return
const method = service.methods.find(m => m.name === methodName)
if (!method || !method.params) return
const method = service.methods.find(m => m.name === methodName)
if (!method || !method.params) return
method.params.forEach(param => {
const required = !param.optional ? ' (обязательно)' : ''
const $paramDiv = $(`
<div class="param">
<label for="param-${param.name}">${param.name}${required} (${param.type}):</label>
<input type="text" id="param-${param.name}" data-param="${param.name}" placeholder="${param.description}">
</div>
`)
$paramsContainer.append($paramDiv)
})
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
? `<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">
<div class="param-header">
<label for="param-${param.name}">${param.name}${required} (${param.type}):</label>
<div class="checkbox-wrapper">
<input type="checkbox" id="defined-${param.name}" data-param="${param.name}" class="defined-checkbox">
<label for="defined-${param.name}">is defined</label>
</div>
</div>
${inputElement}
</div>
`)
$paramsContainer.append($paramDiv)
$(`#defined-${param.name}`).on('change', function() {
const $input = $(`#param-${param.name}`)
const isChecked = $(this).is(':checked')
$input.prop('disabled', !isChecked)
if (!isChecked) {
$input.val(isObjectOrArray ? defaultValue : '')
}
})
// Initially disable optional fields
$(`#param-${param.name}`).prop('disabled', true)
} else {
const $paramDiv = $(`
<div class="param">
<label for="param-${param.name}">${param.name}${required} (${param.type}):</label>
${inputElement}
</div>
`)
$paramsContainer.append($paramDiv)
}
})
}
async function sendRequest() {
const serviceName = $('#service').val()
const methodName = $('#method').val()
const serviceName = $('#service').val()
const methodName = $('#method').val()
if (!serviceName || !methodName) {
$('#result').text('Выберите сервис и метод')
return
}
const params = {}
$('.param input').each(function() {
const paramName = $(this).data('param')
const value = $(this).val()
if (value.trim() !== '') {
params[paramName] = value
if (!serviceName || !methodName) {
$('#result').text('Выберите сервис и метод')
return
}
})
const requestData = {
jsonrpc: '2.0',
method: `${serviceName}.${methodName}`,
params: params,
id: Date.now()
}
const params = {}
let parseError = null
$('#result').text('Отправка запроса...')
try {
const response = await fetch('/api', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
$('.param').each(function () {
const $checkbox = $(this).find('.defined-checkbox')
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() !== '') {
const value = $input.val().trim()
if (isObjectOrArray) {
try {
params[paramName] = JSON.parse(value)
} catch (e) {
parseError = `Ошибка парсинга JSON для поля "${paramName}": ${e.message}`
}
} else {
params[paramName] = value
}
}
} else {
// Required parameter
const value = $input.val().trim()
if (value !== '') {
if (isObjectOrArray) {
try {
params[paramName] = JSON.parse(value)
} catch (e) {
parseError = `Ошибка парсинга JSON для поля "${paramName}": ${e.message}`
}
} else {
params[paramName] = value
}
}
}
})
const result = await response.json()
$('#result').text(JSON.stringify(result, null, 2))
} catch (error) {
$('#result').text(`Ошибка: ${error.message}`)
}
if (parseError) {
$('#result').text(parseError)
$('#request').text(JSON.stringify(requestData, null, 2))
return
}
const requestData = {
jsonrpc: '2.0',
method: `${serviceName}.${methodName}`,
params: params,
id: Date.now()
}
$('#result').text('Отправка запроса...')
$('#request').text('')
try {
const response = await fetch('http://localhost:8080/api', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(requestData)
})
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 === 'logout'
)) {
await loadUserProfile()
}
} catch (error) {
$('#result').text(`Ошибка: ${error.message}`)
$('#request').text(JSON.stringify(requestData, null, 2))
}
}
$(document).ready(async () => {
await loadApiSpec()
if (apiSpec) {
populateServices()
await loadApiSpec()
if (apiSpec) {
populateServices()
// Автоматически загружаем профиль пользователя
await loadUserProfile()
$('#service').on('change', function() {
const serviceName = $(this).val()
populateMethods(serviceName)
$('#params-container').empty()
})
$('#service').on('change', function () {
const serviceName = $(this).val()
populateMethods(serviceName)
$('#params-container').empty()
})
$('#method').on('change', function() {
const serviceName = $('#service').val()
const methodName = $(this).val()
createParamInputs(methodName, serviceName)
})
$('#method').on('change', function () {
const serviceName = $('#service').val()
const methodName = $(this).val()
createParamInputs(methodName, serviceName)
})
$('#send-btn').on('click', sendRequest)
}
$('#send-btn').on('click', sendRequest)
}
})

View File

@ -116,11 +116,11 @@ public class SpecGenerator {
if (type == void.class || type == Void.class) return "void";
if (type.isArray() || Collection.class.isAssignableFrom(type) || type == ArrayNode.class) {
return "Array";
return "array";
}
if (JsonNode.class.isAssignableFrom(type)) {
return "Object";
return "object";
}
return type.getSimpleName();

View File

@ -1,23 +1,86 @@
{
"Auth" : {
"name" : "Auth",
"Profile" : {
"name" : "Profile",
"methods" : [ {
"name" : "generateToken",
"description" : "Generates a new API token and returns its details",
"return" : "Object",
"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" : "permanent",
"type" : "boolean",
"description" : "If true, creates a token that never expires",
"optional" : true
"name" : "login",
"type" : "string",
"description" : "User login. Have to be unique",
"optional" : false
}, {
"name" : "name",
"type" : "string",
"description" : "Display name for the token. If not provided, the User-Agent header will be used",
"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",
@ -34,17 +97,27 @@
"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",
"name" : "getTokens",
"description" : "Retrieves all API tokens associated with the current user",
"return" : "array",
"accessLevel" : "User",
"params" : [ ]
}, {
"name" : "getTokens",
"description" : "Retrieves all API tokens associated with the current user",
"return" : "Array",
"name" : "generateToken",
"description" : "Generates a new API token and returns its details",
"return" : "object",
"accessLevel" : "User",
"params" : [ ]
"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.",
@ -56,44 +129,12 @@
"description" : "API token string for authentication",
"optional" : false
} ]
} ]
},
"Profile" : {
"name" : "Profile",
"methods" : [ {
"name" : "get",
"description" : "",
"return" : "void",
}, {
"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" : "save",
"description" : "",
"return" : "void",
"accessLevel" : "User",
"params" : [ ]
} ]
},
"UserManagement" : {
"name" : "UserManagement",
"methods" : [ {
"name" : "save",
"description" : "",
"return" : "void",
"accessLevel" : "Admin",
"params" : [ ]
}, {
"name" : "getAll",
"description" : "",
"return" : "void",
"accessLevel" : "Admin",
"params" : [ ]
}, {
"name" : "getById",
"description" : "",
"return" : "void",
"accessLevel" : "Admin",
"params" : [ ]
} ]
}
}

View File

@ -18,4 +18,5 @@ public interface RepositoryService extends Service {
ObjectMapper getMapper();
Class<? extends PersistenceEntity> getEntityTypeByName(String entityName);
}

View File

@ -8,13 +8,18 @@ import ru.kirillius.XCP.Commons.Service;
import ru.kirillius.XCP.Logging.Logger;
import ru.kirillius.XCP.Logging.LoggingSystem;
import ru.kirillius.XCP.Logging.LoggingSystemImpl;
import ru.kirillius.XCP.Persistence.PersistenceEntity;
import ru.kirillius.XCP.Persistence.Repository;
import ru.kirillius.XCP.Persistence.RepositoryServiceImpl;
import ru.kirillius.XCP.Security.ConfigManagerImpl;
import ru.kirillius.XCP.Security.SecurityManager;
import ru.kirillius.XCP.Security.SecurityManagerImpl;
import ru.kirillius.XCP.Services.RepositoryService;
import ru.kirillius.XCP.Services.ServiceLoadPriority;
import ru.kirillius.XCP.Services.WebService;
import ru.kirillius.XCP.web.WebServiceImpl;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
@ -85,11 +90,38 @@ public class Application implements Context {
shutdown();
throw new RuntimeException("Error loading services");
}
checkDefaultInstall();
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
((WebServiceImpl) getService(WebService.class)).join();
}
private final static String DEFAULT_INSTALLER_FILE = "defaultEntities.json";
private void checkDefaultInstall() {
try (var stream = getClass().getClassLoader().getResourceAsStream(DEFAULT_INSTALLER_FILE)) {
var repositoryService = getService(RepositoryService.class);
var mapper = repositoryService.getMapper();
var array = (ArrayNode) mapper.readTree(stream);
for (var node : array) {
var entry = (ObjectNode) node;
var typeName = entry.get("type").asString();
var entityType = repositoryService.getEntityTypeByName(typeName);
@SuppressWarnings("unchecked") var repository = (Repository<PersistenceEntity>) repositoryService.getRepositoryForEntity(entityType);
var deserialized = repository.deserialize((ObjectNode) entry.get("entity"));
if (repository.get(deserialized.getUuid()) == null) {
log.warning("Installing default entity " + typeName + " (" + deserialized + ").");
repository.save(deserialized);
}
}
} catch (IOException e) {
throw new RuntimeException("Unable to load file " + DEFAULT_INSTALLER_FILE, e);
}
}
private void loadServices() {
var servicesToLoad = List.of(RepositoryServiceImpl.class, WebServiceImpl.class);

View File

@ -0,0 +1,13 @@
[
{
"type": "User",
"entity": {
"login": "admin",
"name": "Administrator",
"role": "Admin",
"values": {},
"uuid": "00000000-0000-0000-0000-000000000000",
"passwordHash": "$argon2id$v=19$m=65536,t=3,p=1$SBqQtx5adxoG53V0TgqmDw$zIy0Wiq53m9r/SOldtCXWXLWbvZuS0F3HHILxpUsLhQ"
}
}
]

View File

@ -2,12 +2,9 @@ package ru.kirillius.XCP.Security;
import de.mkammerer.argon2.Argon2;
import de.mkammerer.argon2.Argon2Factory;
import lombok.Getter;
public class Argon2HashUtility implements HashUtility {
@Getter
private final static HashUtility instance = new Argon2HashUtility();
private final Argon2 argon2;

View File

@ -0,0 +1,23 @@
package ru.kirillius.XCP.Security;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
class Argon2HashUtilityTest {
@Test
void hash() {
var util = new Argon2HashUtility();
var hashed = util.hash("admin");
assertThat(hashed).isNotNull().isNotEmpty().doesNotContain("admin").isEqualTo("$argon2id$v=19$m=65536,t=3,p=1$SBqQtx5adxoG53V0TgqmDw$zIy0Wiq53m9r/SOldtCXWXLWbvZuS0F3HHILxpUsLhQ");
}
@Test
void validate() {
var util = new Argon2HashUtility();
var hashed = util.hash("admin");
assertThat(util.validate("admin", hashed)).isTrue();
}
}

View File

@ -0,0 +1,55 @@
package ru.kirillius.XCP.Persistence;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.Objects;
import java.util.UUID;
@MappedSuperclass
public abstract class AbstractEntity implements PersistenceEntity {
@Id
@Getter
@Setter
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonProperty
private long id = 0;
@Getter
@Setter
@JsonProperty
@Column(unique = true, nullable = false, updatable = false)
private UUID uuid;
@PrePersist
protected void prePersist() {
if (uuid == null) {
uuid = UUID.randomUUID();
}
}
@Override
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (o == null) {
return false;
}
if (o.getClass() != getClass()) {
return false;
}
var that = (AbstractEntity) o;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hashCode(uuid);
}
}

View File

@ -23,16 +23,11 @@ public class EntityReferenceDeserializer extends StdDeserializer<EntityReference
var id = node.get("id").asLong();
var uuid = node.get("uuid").asString();
try {
@SuppressWarnings("unchecked")
var repository = repositoryService.getRepositoryForEntity((Class<? extends PersistenceEntity>) Class.forName(type));
if (uuid != null) {
return new EntityReference(repository.get(UUID.fromString(uuid)));
}
return new EntityReference(repository.get(id));
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
var repository = repositoryService.getRepositoryForEntity((Class<? extends PersistenceEntity>) repositoryService.getEntityTypeByName(type));
if (uuid != null) {
return new EntityReference(repository.get(UUID.fromString(uuid)));
}
return new EntityReference(repository.get(id));
}
}

View File

@ -22,7 +22,7 @@ public class EntityReferenceSerializer extends StdSerializer<EntityReference> {
}
gen.writeStartObject();
var baseType = repositoryService.getEntityBaseType(value.getClass());
gen.writeStringProperty("type", baseType.getName());
gen.writeStringProperty("type", baseType.getSimpleName());
gen.writeNumberProperty("id", value.getId());
gen.writeStringProperty("uuid", value.getUuid().toString());
gen.writeEndObject();

View File

@ -4,19 +4,13 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.UuidGenerator;
import ru.kirillius.XCP.Commons.StreamHandler;
import ru.kirillius.XCP.Persistence.AbstractRepository;
import ru.kirillius.XCP.Persistence.*;
import ru.kirillius.XCP.Persistence.Entities.ApiToken;
import ru.kirillius.XCP.Persistence.Entities.User;
import ru.kirillius.XCP.Persistence.EntityImplementation;
import ru.kirillius.XCP.Persistence.EntityReference;
import ru.kirillius.XCP.Persistence.RepositoryServiceImpl;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@EntityImplementation(ApiTokenRepositoryImpl.TokenEntity.class)
public class ApiTokenRepositoryImpl extends AbstractRepository<ApiToken> implements ApiTokenRepository {
@ -37,17 +31,7 @@ public class ApiTokenRepositoryImpl extends AbstractRepository<ApiToken> impleme
@NoArgsConstructor
@Getter
@Setter
public static class TokenEntity implements ApiToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonProperty
private long id = 0;
@JsonProperty
@Column(unique = true, nullable = false)
@UuidGenerator
private UUID uuid;
public static class TokenEntity extends AbstractEntity implements ApiToken {
@JsonProperty
@Column
@ -79,15 +63,5 @@ public class ApiTokenRepositoryImpl extends AbstractRepository<ApiToken> impleme
this.user = (UserRepositoryImpl.UserEntity) parent;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof TokenEntity that)) return false;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hashCode(uuid);
}
}
}

View File

@ -4,10 +4,9 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.UuidGenerator;
import ru.kirillius.XCP.Commons.StreamHandler;
import ru.kirillius.XCP.Persistence.Entities.Group;
import ru.kirillius.XCP.Persistence.*;
import ru.kirillius.XCP.Persistence.Entities.Group;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
@ -67,8 +66,6 @@ public class GroupRepositoryImpl extends AbstractNodeRepository<Group> implement
super.save(entity);
}
@Entity
@Table(name = "Groups")
@Builder
@ -76,24 +73,12 @@ public class GroupRepositoryImpl extends AbstractNodeRepository<Group> implement
@NoArgsConstructor
@Getter
@Setter
public static class GroupEntity implements Group {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonProperty
private long id = 0;
@JsonProperty
@Column(unique = true, nullable = false)
@UuidGenerator
private UUID uuid;
public static class GroupEntity extends AbstractEntity implements Group {
@Column(nullable = false)
@JsonProperty
private String name = "";
@Column(nullable = false)
@JsonProperty
@Getter
@ -140,9 +125,6 @@ public class GroupRepositoryImpl extends AbstractNodeRepository<Group> implement
this.parent = (GroupEntity) parent;
}
@Override
public void setTags(TagCollection tags) {
this.tags = tags.stream().map(t -> (TagRepositoryImpl.TagEntity) t).collect(Collectors.toSet());
@ -163,15 +145,5 @@ public class GroupRepositoryImpl extends AbstractNodeRepository<Group> implement
@ManyToMany(fetch = FetchType.EAGER)
private Set<TagRepositoryImpl.TagEntity> tags = new HashSet<>();
@Override
public boolean equals(Object o) {
if (!(o instanceof GroupEntity that)) return false;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hashCode(uuid);
}
}
}

View File

@ -4,12 +4,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.UuidGenerator;
import ru.kirillius.XCP.Data.PollSettings;
import ru.kirillius.XCP.Data.ValueTransformationChain;
import ru.kirillius.XCP.Persistence.*;
import ru.kirillius.XCP.Persistence.Entities.Group;
import ru.kirillius.XCP.Persistence.Entities.Input;
import ru.kirillius.XCP.Persistence.*;
import ru.kirillius.XCP.Serialization.PollSettingsConverter;
import ru.kirillius.XCP.Serialization.ValueTransformationChainConverter;
import tools.jackson.databind.annotation.JsonDeserialize;
@ -17,9 +16,7 @@ import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@EntityImplementation(InputRepositoryImpl.InputEntity.class)
@ -37,8 +34,6 @@ public class InputRepositoryImpl extends AbstractNodeRepository<Input> implement
super.save(entity);
}
@Entity
@Table(name = "Inputs")
@Builder
@ -46,17 +41,7 @@ public class InputRepositoryImpl extends AbstractNodeRepository<Input> implement
@NoArgsConstructor
@Getter
@Setter
public static class InputEntity implements Input {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonProperty
private long id = 0;
@JsonProperty
@Column(unique = true, nullable = false)
@UuidGenerator
private UUID uuid;
public static class InputEntity extends AbstractEntity implements Input {
@Column(nullable = false)
@JsonProperty
@ -116,17 +101,6 @@ public class InputRepositoryImpl extends AbstractNodeRepository<Input> implement
@ManyToMany(fetch = FetchType.EAGER)
private Set<TagRepositoryImpl.TagEntity> tags = new HashSet<>();
@Override
public boolean equals(Object o) {
if (!(o instanceof InputEntity that)) return false;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hashCode(uuid);
}
@Override
public PollSettings getPollSettings() {
return pollSettings;
@ -140,7 +114,7 @@ public class InputRepositoryImpl extends AbstractNodeRepository<Input> implement
@Column(nullable = false)
@JsonProperty
@Convert(converter = PollSettingsConverter.class)
@JsonDeserialize(as = PollSettingsImpl.class)
@JsonDeserialize(as = PollSettingsImpl.class)
private PollSettingsImpl pollSettings = new PollSettingsImpl();
@Column(nullable = false)

View File

@ -4,19 +4,16 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.UuidGenerator;
import ru.kirillius.XCP.Data.ValueTransformationChain;
import ru.kirillius.XCP.Persistence.*;
import ru.kirillius.XCP.Persistence.Entities.Group;
import ru.kirillius.XCP.Persistence.Entities.Output;
import ru.kirillius.XCP.Persistence.*;
import ru.kirillius.XCP.Serialization.ValueTransformationChainConverter;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@EntityImplementation(OutputRepositoryImpl.OutputEntity.class)
@ -34,8 +31,6 @@ public class OutputRepositoryImpl extends AbstractNodeRepository<Output> impleme
super.save(entity);
}
@Entity
@Table(name = "Outputs")
@Builder
@ -43,16 +38,7 @@ public class OutputRepositoryImpl extends AbstractNodeRepository<Output> impleme
@NoArgsConstructor
@Getter
@Setter
public static class OutputEntity implements Output {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonProperty
private long id = 0;
@JsonProperty
@Column(unique = true, nullable = false)
@UuidGenerator
private UUID uuid;
public static class OutputEntity extends AbstractEntity implements Output {
@Column(nullable = false)
@JsonProperty
@ -112,18 +98,6 @@ public class OutputRepositoryImpl extends AbstractNodeRepository<Output> impleme
@ManyToMany(fetch = FetchType.EAGER)
private Set<TagRepositoryImpl.TagEntity> tags = new HashSet<>();
@Override
public boolean equals(Object o) {
if (!(o instanceof OutputEntity that)) return false;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hashCode(uuid);
}
@Column(nullable = false)
@JsonProperty
@Getter
@ -136,6 +110,5 @@ public class OutputRepositoryImpl extends AbstractNodeRepository<Output> impleme
@Convert(converter = ValueTransformationChainConverter.class)
@Column(nullable = false)
private ValueTransformationChain transformationChain = new ValueTransformationChain();
}
}

View File

@ -1,9 +1,10 @@
package ru.kirillius.XCP.Persistence.Repositories;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.*;
import org.hibernate.annotations.UuidGenerator;
import ru.kirillius.XCP.Commons.ResourceHandler;
import ru.kirillius.XCP.Persistence.*;
import ru.kirillius.XCP.Persistence.Entities.Tag;
@ -11,8 +12,6 @@ import ru.kirillius.XCP.Persistence.Entities.Tag;
import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Stream;
@ -50,12 +49,10 @@ public class TagRepositoryImpl extends AbstractRepository<Tag> implements TagRep
throw new RuntimeException("Unable to find tags by names " + names, e);
}
if (tags.size() != names.size()) {
var foundNames = tags.stream().map(Tag::getName).toList();
names.forEach(tagName -> {
if(!foundNames.contains(tagName)) {
if (!foundNames.contains(tagName)) {
var tag = create();
tag.setName(tagName);
save(tag);
@ -84,16 +81,7 @@ public class TagRepositoryImpl extends AbstractRepository<Tag> implements TagRep
@NoArgsConstructor
@Getter
@Setter
public static class TagEntity implements Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonProperty
private long id = 0;
@JsonProperty
@Column(unique = true, nullable = false)
@UuidGenerator
private UUID uuid;
public static class TagEntity extends AbstractEntity implements Tag {
@Override
public String toString() {
@ -126,16 +114,5 @@ public class TagRepositoryImpl extends AbstractRepository<Tag> implements TagRep
this.name = name;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof TagEntity tagEntity)) return false;
return Objects.equals(name, tagEntity.name);
}
@Override
public int hashCode() {
return Objects.hashCode(name);
}
}
}

View File

@ -4,20 +4,14 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.UuidGenerator;
import ru.kirillius.XCP.Commons.Context;
import ru.kirillius.XCP.Persistence.AbstractRepository;
import ru.kirillius.XCP.Persistence.ContextReferencedEntity;
import ru.kirillius.XCP.Persistence.*;
import ru.kirillius.XCP.Persistence.Entities.User;
import ru.kirillius.XCP.Persistence.EntityImplementation;
import ru.kirillius.XCP.Persistence.RepositoryServiceImpl;
import ru.kirillius.XCP.Security.UserRole;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.util.Objects;
import java.util.UUID;
@EntityImplementation(UserRepositoryImpl.UserEntity.class)
public class UserRepositoryImpl extends AbstractRepository<User> implements UserRepository {
@ -42,23 +36,13 @@ public class UserRepositoryImpl extends AbstractRepository<User> implements User
@NoArgsConstructor
@Getter
@Setter
public static class UserEntity implements User, ContextReferencedEntity {
public static class UserEntity extends AbstractEntity implements User, ContextReferencedEntity {
@Transient
@JsonIgnore
@Getter
@Setter
private Context context;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonProperty
private long id = 0;
@JsonProperty
@Column(unique = true, nullable = false)
@UuidGenerator
private UUID uuid;
@Column(nullable = false, unique = true)
@JsonProperty
private String login = "";
@ -82,6 +66,14 @@ public class UserRepositoryImpl extends AbstractRepository<User> implements User
@JsonProperty
private ObjectNode values = JsonNodeFactory.instance.objectNode();
@Override
protected void prePersist() {
super.prePersist();
if (login == null || login.isEmpty()) {
login = "user" + System.currentTimeMillis();
}
}
@Override
public void setPassword(String password) {
passwordHash = context.getSecurityManager().getHashUtility().hash(password);
@ -91,16 +83,5 @@ public class UserRepositoryImpl extends AbstractRepository<User> implements User
public boolean verifyPassword(String password) {
return context.getSecurityManager().getHashUtility().validate(password, passwordHash);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof UserEntity that)) return false;
return Objects.equals(uuid, that.uuid);
}
@Override
public int hashCode() {
return Objects.hashCode(uuid);
}
}
}

View File

@ -220,4 +220,9 @@ public final class RepositoryServiceImpl implements RepositoryService {
return repositoryEntityBindings.get(repositoryImplClass);
}
@Override
public Class<? extends PersistenceEntity> getEntityTypeByName(String entityName) {
return entityBaseBindings.values().stream().filter(e -> e.getSimpleName().equals(entityName)).findFirst().orElse(null);
}
}

View File

@ -56,7 +56,6 @@ class RepositoryServiceImplTest {
@Setter
private UUID uuid;
@Override
public boolean equals(Object o) {
if (!(o instanceof EntityImpl entity)) return false;
@ -187,4 +186,18 @@ class RepositoryServiceImplTest {
}
}
@Test
public void TestGetByName() {
try (var service = instantiateTestService(List.of(RepoImpl.class))) {
var repository = service.getRepository(TestRepository.class);
var testEntity = repository.create();
repository.save(List.of(testEntity));
var typeByName = service.getEntityTypeByName(TestEntity.class.getSimpleName());
assertThat(typeByName).isNotNull().isEqualTo(TestEntity.class);
}
}
}

View File

@ -170,6 +170,13 @@ public class JsonRpcServlet extends HttpServlet {
.build();
}
private JsonRpcResponse createErrorResponse(JsonRpcRequest request, JsonRpcErrorCode code, String message) {
return JsonRpcResponse.builder().jsonrpc("2.0")
.id(request == null ? -1L : request.getId())
.error(new JsonRpcError(code, message))
.build();
}
private JsonRpcResponse processRequest(JsonRpcRequest request, CallContext callContext) {
var split = request.getMethod().split(Pattern.quote("."), 2);
if (split.length != 2) {
@ -188,14 +195,15 @@ public class JsonRpcServlet extends HttpServlet {
if (callContext.getCurrentUser().getRole().getLevel() < methodBinding.getAccessLevel().getLevel()) {
callContext.getResponse().setStatus(HttpServletResponse.SC_FORBIDDEN);
return createErrorResponse(request, JsonRpcErrorCode.INTERNAL_ERROR);
log.error("Forbidden JSON-RPC request: " + mapper.writerWithDefaultPrettyPrinter().writeValueAsString(request));
return createErrorResponse(request, JsonRpcErrorCode.INTERNAL_ERROR, "Forbidden due to access level");
}
try {
var result = methodBinding.getMethod().invoke(methodBinding.getService(), callContext);
return JsonRpcResponse.builder().id(request.getId()).result(result).build();
return JsonRpcResponse.builder().id(request.getId()).jsonrpc("2.0").result(result).build();
} catch (IllegalAccessException | InvocationTargetException e) {
log.error("Failed to process JSON-RPC request: " + mapper.valueToTree(request).toString(), e);
log.error("Failed to process JSON-RPC request: " + mapper.writerWithDefaultPrettyPrinter().valueToTree(request).toString(), e);
return createErrorResponse(request, JsonRpcErrorCode.INVOCATION_ERROR);
}

View File

@ -11,7 +11,16 @@ import tools.jackson.databind.node.ObjectNode;
public class Profile extends JsonRpcService {
@JsonRpcMethod(accessLevel = UserRole.User)
@JsonRpcMethod(
accessLevel = UserRole.User,
description = "edit current user profile",
parameters = {
@JsonRpcMethod.Parameter(name = "login", description = "User login. Have to be unique", type = String.class),
@JsonRpcMethod.Parameter(name = "name", description = "User display name", type = String.class),
@JsonRpcMethod.Parameter(name = "password", description = "Change user password if defined", type = String.class, optional = true),
@JsonRpcMethod.Parameter(name = "values", description = "User custom values", type = ObjectNode.class)
},
returnType = boolean.class)
public boolean save(CallContext call) {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
var user = call.getCurrentUser();
@ -49,7 +58,10 @@ public class Profile extends JsonRpcService {
return true;
}
@JsonRpcMethod(accessLevel = UserRole.User)
@JsonRpcMethod(
accessLevel = UserRole.Guest,
description = "Get current user profile"
)
public ObjectNode get(CallContext call) {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
var user = call.getCurrentUser();

View File

@ -13,7 +13,14 @@ import tools.jackson.databind.node.ObjectNode;
import java.io.IOException;
public class UserManagement extends JsonRpcService {
@JsonRpcMethod(accessLevel = UserRole.Admin)
@JsonRpcMethod(
accessLevel = UserRole.Admin,
description = "Get all users as list",
parameters = {
},
returnType = ArrayNode.class
)
public ArrayNode getAll(CallContext call) throws IOException {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
try (var handler = userRepository.getAll()) {
@ -21,18 +28,51 @@ public class UserManagement extends JsonRpcService {
}
}
@JsonRpcMethod(accessLevel = UserRole.Admin)
@JsonRpcMethod(
accessLevel = UserRole.Admin,
description = "Get all users as list",
parameters = {
@JsonRpcMethod.Parameter(name = "user", description = "User object to save", type = ObjectNode.class)
},
returnType = ArrayNode.class
)
public void save(CallContext call) {
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
var user = userRepository.deserialize((ObjectNode) requireParam(call, "user"));
userRepository.save(user);
}
@JsonRpcMethod(accessLevel = UserRole.Admin)
@JsonRpcMethod(
accessLevel = UserRole.Admin,
description = "Load user object by ID",
parameters = {
@JsonRpcMethod.Parameter(name = "id", description = "User id", type = int.class)
},
returnType = ObjectNode.class
)
public ObjectNode getById(CallContext call) {
var userId = requireParam(call, "id", JsonNode::asLong);
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
return userRepository.serialize(userRepository.get(userId));
}
@JsonRpcMethod(
accessLevel = UserRole.Admin,
description = "Remove user by ID",
parameters = {
@JsonRpcMethod.Parameter(name = "id", description = "User id", type = int.class)
},
returnType = ObjectNode.class
)
public boolean remove(CallContext call) {
var userId = requireParam(call, "id", JsonNode::asLong);
var userRepository = call.getContext().getService(RepositoryService.class).getRepository(UserRepository.class);
var user = userRepository.get(userId);
if (user == null) {
return false;
}
userRepository.remove(user);
return true;
}
}