UserSettingsRequestBody.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.model.api;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import tools.jackson.databind.JsonNode;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Body of a user-resource update: every member is an independent, optional instruction.
*
* <p>Components are {@link JsonNode} rather than {@code String} because this endpoint distinguishes
* three states per member, and only a node carries all three: <em>absent</em> arrives as {@code null}
* and leaves the setting alone, an explicit {@code null} arrives as a null node and is a malformed
* instruction, and a value of the wrong JSON type is likewise malformed. {@code Optional<String>}
* cannot express this — Jackson resolves both absent and explicit-null to {@code Optional.empty()},
* so a caller sending {@code "language": null} would be indistinguishable from one that never
* mentioned the field, silently clearing nothing instead of reporting a bad body.
*
* <p>A member that is present but not of the expected type is rejected with 400; only an absent member
* leaves the setting untouched. The accessors below apply that rule once per type, so callers read a
* member and get either a usable value or a rejection.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record UserSettingsRequestBody(
@JsonProperty("name") JsonNode name,
@JsonProperty("password") JsonNode password,
@JsonProperty("currentPassword") JsonNode currentPassword,
@JsonProperty("language") JsonNode language,
@JsonProperty("homecontext") JsonNode homeContext,
@JsonProperty("disabled") JsonNode disabled,
@JsonProperty("customProperties") JsonNode customProperties) {
/**
* Message for every malformed member. Client-visible wire string that existing clients and the
* resource integration tests assert verbatim — do not reword.
*/
public static final String SYNTAX_ERROR_MESSAGE = "Error in JSON syntax";
public boolean hasName() {
return name != null;
}
public boolean hasPassword() {
return password != null;
}
public boolean hasCurrentPassword() {
return currentPassword != null;
}
public boolean hasLanguage() {
return language != null;
}
public boolean hasHomeContext() {
return homeContext != null;
}
public boolean hasDisabled() {
return disabled != null;
}
public boolean hasCustomProperties() {
return customProperties != null;
}
public String nameValue() {
return stringValue(name);
}
public String passwordValue() {
return stringValue(password);
}
public String currentPasswordValue() {
return stringValue(currentPassword);
}
public String languageValue() {
return stringValue(language);
}
public String homeContextValue() {
return stringValue(homeContext);
}
/**
* Wire contract: a JSON boolean, or the strings {@code "true"} / {@code "false"} case-insensitively.
* The string form is not a hypothetical — clients send {@code "disabled": "true"} and expect the
* account to end up disabled, so accepting only a JSON boolean turns those requests into silent
* no-ops.
*
* <p>Anything else is rejected rather than resolved to false. That is stricter than the
* {@code optBoolean("disabled", false)} this replaced, deliberately: this member is a security
* control, and defaulting a malformed value to false answers 200 UPDATED while leaving an account
* enabled — or, against an already-disabled account, silently re-enabling it.
*/
public boolean disabledValue() {
if (disabled == null) {
return false;
}
if (disabled.isBoolean()) {
return disabled.booleanValue();
}
if (disabled.isString() && ("true".equalsIgnoreCase(disabled.stringValue())
|| "false".equalsIgnoreCase(disabled.stringValue()))) {
return Boolean.parseBoolean(disabled.stringValue());
}
throw new BadRequestException(SYNTAX_ERROR_MESSAGE);
}
/** Requires a JSON object whose values are all strings. */
public Map<String, String> customPropertiesValue() {
if (customProperties == null || !customProperties.isObject()) {
throw new BadRequestException(SYNTAX_ERROR_MESSAGE);
}
Map<String, String> properties = new LinkedHashMap<>();
customProperties.propertyStream()
.forEach(property -> properties.put(property.getKey(), stringValue(property.getValue())));
return properties;
}
/**
* Strict on purpose: a number or boolean where a string belongs is a client mistake, and coercing it
* would let {@code "language": 5} be stored as the language tag {@code "5"}.
*/
private static String stringValue(JsonNode node) {
if (node == null || !node.isString()) {
throw new BadRequestException(SYNTAX_ERROR_MESSAGE);
}
return node.stringValue();
}
}