SignupRequestBody.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.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonSetter;
import jakarta.validation.constraints.NotEmpty;
import org.entrystore.rest.springboot.model.validation.AuthValidationMessages;
import org.entrystore.rest.springboot.model.validation.ValidEmail;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Body of {@code POST /auth/signup}, in both its JSON and form-encoded forms.
*
* <p>{@code @NotEmpty} rather than {@code @NotBlank} throughout: the checks this replaced tested
* {@code StringUtils.isNotEmpty}, so an all-whitespace value counts as supplied and is rejected
* further along by the rule that actually applies to it — a malformed address for {@code email}, the
* configured password rules for {@code password}, the name rules for the two name fields. Switching to
* {@code @NotBlank} would report all four as missing instead.
*
* <p>Password rules and name format are checked in {@code AuthService} rather than here, because both
* need more than the field's own value: the password rules apply to the trimmed password, and the name rules
* span two fields.
*
* <p>Message precedence, when a body breaks several rules at once: every constraint on this record
* first — presence and address format, resolved in component-declaration order — and only then the
* service-side password rules and name format. Component order is therefore a client-visible contract;
* reordering the components below changes which message a caller receives.
*
* <p>This is <em>not</em> the precedence the sequential {@code if} chain had. That chain interleaved
* per field, checking the password before the two names' presence, so a body with both a password that
* breaks the rules and a missing surname answered "The password must conform to the configured rules." and now
* answers "One or more parameters are missing.". Every single-fault body is unaffected, which is why no
* integration test moves.
*/
public record SignupRequestBody(
@NotEmpty(message = AuthValidationMessages.PARAMETERS_MISSING)
@ValidEmail
String email,
@NotEmpty(message = AuthValidationMessages.PARAMETERS_MISSING)
String password,
@JsonSetter("firstname")
@NotEmpty(message = AuthValidationMessages.PARAMETERS_MISSING)
String firstName,
@JsonSetter("lastname")
@NotEmpty(message = AuthValidationMessages.PARAMETERS_MISSING)
String lastName,
@JsonSetter("urlsuccess") String urlSuccess,
@JsonSetter("urlfailure") String urlFailure,
@JsonSetter("grecaptcharesponse") String rcResponseV2,
/**
* Body members that match none of the components above. Sign-up carries caller-defined
* {@code custom_*} properties onto the new user, so the unrecognised remainder is data, not
* something to reject or discard, up to the {@code custom_*} count that {@code AuthService.signup} caps.
*/
@JsonAnySetter Map<String, String> extraProperties) {
public SignupRequestBody {
// Lower-cased here rather than only in SignupInfo.setEmail so that the address echoed by a
// @ValidEmail violation is the same one the rest of the flow uses. Locale.ROOT, matching the
// sign-up domain whitelist comparison: under a locale whose lower-casing differs from the root
// one (Turkish dotless i) a default-locale fold would produce a domain the whitelist never
// matches.
email = email == null ? null : email.toLowerCase(Locale.ROOT);
// Never null, so callers can iterate without a guard. Jackson omits the any-setter map entirely
// when the body carries no unrecognised members.
//
// Null values are dropped rather than handed to Map.copyOf, which rejects them. A client that
// serialises its empty optional fields sends {"custom_dept": null}; the any-setter stores that
// null, and copyOf would throw inside the record constructor, which Jackson rewraps until it
// reaches the caller as a bare JSON "Bad Request" — refusing a sign-up that the previous
// untyped Map binding completed, and in JSON where this endpoint answers HTML.
extraProperties = extraProperties == null ? Map.of() : extraProperties.entrySet().stream()
.filter(property -> property.getValue() != null)
.collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));
}
/**
* Convenience for the form-encoded endpoint, which cannot bind {@code g-recaptcha-response} to a
* record component and so assembles the body itself.
*/
public static SignupRequestBody fromFormParameters(Map<String, String> parameters) {
Map<String, String> extras = new LinkedHashMap<>(parameters);
extras.keySet().removeAll(Set.of("email", "password", "firstname", "lastname",
"urlsuccess", "urlfailure", "g-recaptcha-response"));
return new SignupRequestBody(
parameters.get("email"),
parameters.get("password"),
parameters.get("firstname"),
parameters.get("lastname"),
parameters.get("urlsuccess"),
parameters.get("urlfailure"),
parameters.get("g-recaptcha-response"),
extras);
}
}