supportedLocales,
+ String defaultLocale) {}
+}
diff --git a/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java
new file mode 100644
index 0000000..1908421
--- /dev/null
+++ b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleImporter.java
@@ -0,0 +1,159 @@
+package cloud.nstart.terravault.ordinis.cuod;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
+import cloud.nstart.terravault.ordinis.events.EventEnvelope;
+import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated;
+import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
+import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * При старте сервиса проверяет/создаёт справочники из ЦУОД bundle.
+ *
+ * Идемпотентен: если справочник уже существует — сравнивает schema_version,
+ * при отличии и {@code updateExistingSchema=true} обновляет (с DefinitionUpdated
+ * outbox event). В противном случае не трогает.
+ */
+@Service
+@Configuration
+@EnableConfigurationProperties(CuodBundleProperties.class)
+@ConditionalOnProperty(name = "ordinis.bundle.cuod.auto-import", havingValue = "true", matchIfMissing = false)
+public class CuodBundleImporter {
+
+ private static final Logger log = LoggerFactory.getLogger(CuodBundleImporter.class);
+
+ private final CuodBundleProperties props;
+ private final DictionaryDefinitionRepository repository;
+ private final OutboxRecorder outboxRecorder;
+ private final ObjectMapper objectMapper;
+ private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
+
+ public CuodBundleImporter(
+ CuodBundleProperties props,
+ DictionaryDefinitionRepository repository,
+ OutboxRecorder outboxRecorder,
+ ObjectMapper objectMapper) {
+ this.props = props;
+ this.repository = repository;
+ this.outboxRecorder = outboxRecorder;
+ this.objectMapper = objectMapper;
+ }
+
+ @Transactional
+ public void importBundle() throws Exception {
+ Resource manifestResource = resolver.getResource(props.resourceRoot() + "manifest.json");
+ if (!manifestResource.exists()) {
+ log.warn("ЦУОД bundle manifest не найден по пути {}, пропускаем auto-import", props.resourceRoot());
+ return;
+ }
+
+ BundleManifest manifest;
+ try (var in = manifestResource.getInputStream()) {
+ manifest = objectMapper.readValue(in, BundleManifest.class);
+ }
+ log.info("Импорт ЦУОД bundle '{}' v{}: {} справочников",
+ manifest.bundle(), manifest.version(), manifest.dictionaries().size());
+
+ for (BundleManifest.DictionaryEntry entry : manifest.dictionaries()) {
+ try {
+ importEntry(manifest, entry);
+ } catch (Exception e) {
+ log.error("Ошибка импорта справочника '{}': {}", entry.name(), e.getMessage(), e);
+ }
+ }
+
+ log.info("Импорт ЦУОД bundle '{}' завершён", manifest.bundle());
+ }
+
+ private void importEntry(BundleManifest manifest, BundleManifest.DictionaryEntry entry) throws Exception {
+ Resource schemaResource = resolver.getResource(props.resourceRoot() + entry.schemaResource());
+ JsonNode schema;
+ try (var in = schemaResource.getInputStream()) {
+ schema = objectMapper.readTree(in);
+ }
+
+ List entryLocales = entry.supportedLocales() == null || entry.supportedLocales().isEmpty()
+ ? manifest.supportedLocales()
+ : entry.supportedLocales();
+ String entryDefault = entry.defaultLocale() == null ? manifest.defaultLocale() : entry.defaultLocale();
+
+ Optional existing = repository.findByName(entry.name());
+ if (existing.isPresent()) {
+ DictionaryDefinition def = existing.get();
+ if (!props.updateExistingSchema()) {
+ log.debug("Справочник '{}' уже существует, update_existing_schema=false — пропускаем", entry.name());
+ return;
+ }
+ if (entry.schemaVersion().equals(def.getSchemaVersion())) {
+ log.debug("Справочник '{}' v{} актуален", entry.name(), def.getSchemaVersion());
+ return;
+ }
+ JsonNode prevSchema = def.getSchemaJson();
+ def.setSchemaJson(schema);
+ def.setSchemaVersion(entry.schemaVersion());
+ def.setDescription(entry.description());
+ def.setDisplayName(entry.displayName());
+ def.setSupportedLocales(entryLocales.toArray(String[]::new));
+ def.setDefaultLocale(entryDefault);
+ DictionaryDefinition saved = repository.save(def);
+
+ var event = new DictionaryDefinitionUpdated(
+ saved.getId(), saved.getName(), saved.getDisplayName(), saved.getDescription(),
+ toEventScope(saved.getScope()), saved.getSchemaJson(), prevSchema,
+ saved.getSchemaVersion(), saved.getBundle(),
+ List.of(saved.getSupportedLocales()), saved.getDefaultLocale(),
+ saved.getUpdatedAt(), saved.getUpdatedBy());
+ outboxRecorder.record(
+ "DefinitionUpdated", "DictionaryDefinition", saved.getId().toString(),
+ saved.getName(), saved.getScope(), manifest.bundle(),
+ "definition:" + saved.getName(),
+ EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), manifest.bundle(), event));
+ log.info("Справочник '{}' обновлён до v{}", saved.getName(), saved.getSchemaVersion());
+ return;
+ }
+
+ DictionaryDefinition fresh = new DictionaryDefinition(
+ UUID.randomUUID(), entry.name(), entry.scope(), schema);
+ fresh.setDisplayName(entry.displayName());
+ fresh.setDescription(entry.description());
+ fresh.setSchemaVersion(entry.schemaVersion());
+ fresh.setBundle(manifest.bundle());
+ fresh.setSupportedLocales(entryLocales.toArray(String[]::new));
+ fresh.setDefaultLocale(entryDefault);
+ DictionaryDefinition saved = repository.save(fresh);
+
+ var event = new DictionaryDefinitionCreated(
+ saved.getId(), saved.getName(), saved.getDisplayName(), saved.getDescription(),
+ toEventScope(saved.getScope()), saved.getSchemaJson(),
+ saved.getSchemaVersion(), saved.getBundle(),
+ List.of(saved.getSupportedLocales()), saved.getDefaultLocale(),
+ saved.getCreatedAt(), saved.getCreatedBy());
+ outboxRecorder.record(
+ "DefinitionCreated", "DictionaryDefinition", saved.getId().toString(),
+ saved.getName(), saved.getScope(), manifest.bundle(),
+ "definition:" + saved.getName(),
+ EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), manifest.bundle(), event));
+ log.info("Справочник '{}' создан (scope={}, locales={}, default={})",
+ saved.getName(), saved.getScope(), entryLocales, entryDefault);
+ }
+
+ private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(DataScope d) {
+ return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
+ }
+}
diff --git a/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleProperties.java b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleProperties.java
new file mode 100644
index 0000000..8a22e85
--- /dev/null
+++ b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleProperties.java
@@ -0,0 +1,14 @@
+package cloud.nstart.terravault.ordinis.cuod;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "ordinis.bundle.cuod")
+public record CuodBundleProperties(
+ boolean autoImport,
+ boolean updateExistingSchema,
+ String resourceRoot) {
+
+ public CuodBundleProperties {
+ if (resourceRoot == null) resourceRoot = "classpath:/bundles/cuod/";
+ }
+}
diff --git a/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleStartupRunner.java b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleStartupRunner.java
new file mode 100644
index 0000000..4aed7d5
--- /dev/null
+++ b/ordinis-cuod-bundle/src/main/java/cloud/nstart/terravault/ordinis/cuod/CuodBundleStartupRunner.java
@@ -0,0 +1,23 @@
+package cloud.nstart.terravault.ordinis.cuod;
+
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Триггерит {@link CuodBundleImporter#importBundle()} при старте Spring Boot.
+ * Отделено от Importer в свой Configuration чтобы Importer оставался
+ * proxified Spring service с работающим @Transactional.
+ */
+@Configuration
+@ConditionalOnProperty(name = "ordinis.bundle.cuod.auto-import", havingValue = "true", matchIfMissing = false)
+public class CuodBundleStartupRunner {
+
+ @Bean
+ @ConditionalOnBean(CuodBundleImporter.class)
+ public ApplicationRunner cuodBundleImportRunner(CuodBundleImporter importer) {
+ return args -> importer.importBundle();
+ }
+}
diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/ground_station.schema.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/ground_station.schema.json
new file mode 100644
index 0000000..ca3bcc5
--- /dev/null
+++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/ground_station.schema.json
@@ -0,0 +1,105 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://ordinis.nstart.cloud/schemas/cuod/ground_station/1.0.0",
+ "title": "Ground Station",
+ "description": "Наземная станция приёма и/или управления КА. Геометрия (точка) пишется в отдельную колонку geometry, не дублируется в data.lat/lon.",
+ "type": "object",
+ "required": ["code", "name"],
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Уникальный код станции",
+ "pattern": "^[A-Z][A-Z0-9_-]{1,31}$"
+ },
+ "name": {
+ "type": "object",
+ "x-localized": true,
+ "patternProperties": {
+ "^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
+ },
+ "required": ["ru-RU"]
+ },
+ "location": {
+ "type": "object",
+ "x-localized": true,
+ "description": "Текстовое описание местоположения (город, регион, страна)",
+ "patternProperties": {
+ "^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
+ }
+ },
+ "country": {
+ "type": "string",
+ "description": "ISO 3166-1 alpha-2",
+ "pattern": "^[A-Z]{2}$"
+ },
+ "lat": {
+ "type": "number",
+ "description": "Широта (для удобства поиска без geometry; geometry — source of truth)",
+ "minimum": -90,
+ "maximum": 90
+ },
+ "lon": {
+ "type": "number",
+ "minimum": -180,
+ "maximum": 180
+ },
+ "elevation_m": {
+ "type": "number",
+ "description": "Высота над уровнем моря, м"
+ },
+ "elevation_min_deg": {
+ "type": "number",
+ "description": "Минимальный угол места, градусы (горизонт станции)",
+ "minimum": 0,
+ "maximum": 90
+ },
+ "antennas": {
+ "type": "array",
+ "description": "Антенные системы станции",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "diameter_m", "bands"],
+ "properties": {
+ "id": { "type": "string" },
+ "diameter_m": { "type": "number", "minimum": 0 },
+ "bands": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["L_BAND", "S_BAND", "C_BAND", "X_BAND", "KU_BAND", "KA_BAND", "UHF", "VHF"]
+ },
+ "minItems": 1,
+ "uniqueItems": true
+ },
+ "polarizations": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["LHCP", "RHCP", "H", "V"]
+ }
+ },
+ "tx_capable": { "type": "boolean", "default": false },
+ "rx_capable": { "type": "boolean", "default": true },
+ "g_t_db_per_k": {
+ "type": "number",
+ "description": "G/T (gain-to-noise-temperature ratio), дБ/К"
+ },
+ "eirp_dbw": { "type": "number" }
+ }
+ }
+ },
+ "operator": {
+ "type": "object",
+ "x-localized": true,
+ "patternProperties": {
+ "^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
+ }
+ },
+ "status": {
+ "type": "string",
+ "enum": ["PLANNED", "OPERATIONAL", "MAINTENANCE", "DECOMMISSIONED"]
+ }
+ }
+}
diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json
new file mode 100644
index 0000000..e547f75
--- /dev/null
+++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json
@@ -0,0 +1,34 @@
+{
+ "bundle": "cuod",
+ "displayName": "ЦУОД ДЗЗ",
+ "description": "Bundle справочников для Центра Управления Обработкой Данных ДЗЗ. Anchor v1.",
+ "version": "1.0.0",
+ "supportedLocales": ["ru-RU", "en-US"],
+ "defaultLocale": "ru-RU",
+ "dictionaries": [
+ {
+ "name": "spacecraft",
+ "displayName": "Космические аппараты",
+ "description": "Каталог КА (космических аппаратов) ДЗЗ-миссий",
+ "scope": "PUBLIC",
+ "schemaResource": "spacecraft.schema.json",
+ "schemaVersion": "1.0.0"
+ },
+ {
+ "name": "satellite_type",
+ "displayName": "Типы КА",
+ "description": "Классификация типов космических аппаратов по миссиям и характеристикам",
+ "scope": "PUBLIC",
+ "schemaResource": "satellite_type.schema.json",
+ "schemaVersion": "1.0.0"
+ },
+ {
+ "name": "ground_station",
+ "displayName": "Наземные станции",
+ "description": "Каталог наземных пунктов приёма и управления",
+ "scope": "PUBLIC",
+ "schemaResource": "ground_station.schema.json",
+ "schemaVersion": "1.0.0"
+ }
+ ]
+}
diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/satellite_type.schema.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/satellite_type.schema.json
new file mode 100644
index 0000000..ae9369e
--- /dev/null
+++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/satellite_type.schema.json
@@ -0,0 +1,69 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://ordinis.nstart.cloud/schemas/cuod/satellite_type/1.0.0",
+ "title": "Satellite Type",
+ "description": "Тип КА — классификация по миссии и массовому классу. Используется как FK в spacecraft.satellite_type_code.",
+ "type": "object",
+ "required": ["code", "name", "mission_category"],
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "type": "string",
+ "description": "Уникальный код типа",
+ "pattern": "^[A-Z][A-Z0-9_]{1,31}$"
+ },
+ "name": {
+ "type": "object",
+ "x-localized": true,
+ "patternProperties": {
+ "^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
+ },
+ "required": ["ru-RU"]
+ },
+ "description": {
+ "type": "object",
+ "x-localized": true,
+ "patternProperties": {
+ "^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
+ }
+ },
+ "mission_category": {
+ "type": "string",
+ "enum": [
+ "OPTICAL_HIRES",
+ "OPTICAL_MIDRES",
+ "OPTICAL_LOWRES",
+ "RADAR_SAR",
+ "HYPERSPECTRAL",
+ "THERMAL",
+ "METEO",
+ "ATMOSPHERIC",
+ "OCEAN",
+ "GEODESY"
+ ]
+ },
+ "mass_class": {
+ "type": "string",
+ "description": "Массовый класс (по конвенциям ESA/NASA)",
+ "enum": ["FEMTO", "PICO", "NANO", "MICRO", "MINI", "SMALL", "MEDIUM", "LARGE"]
+ },
+ "typical_orbit_classes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["LEO", "MEO", "GEO", "HEO", "SSO"]
+ },
+ "uniqueItems": true
+ },
+ "typical_resolution_m": {
+ "type": "number",
+ "description": "Типичное пространственное разрешение, м",
+ "minimum": 0
+ },
+ "typical_revisit_hours": {
+ "type": "number",
+ "description": "Типичный период повторного покрытия, часов",
+ "minimum": 0
+ }
+ }
+}
diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json
new file mode 100644
index 0000000..e9fb4c9
--- /dev/null
+++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/spacecraft.schema.json
@@ -0,0 +1,99 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://ordinis.nstart.cloud/schemas/cuod/spacecraft/1.0.0",
+ "title": "Spacecraft (КА)",
+ "description": "Космический аппарат ДЗЗ. Идентифицируется designator'ом (международное обозначение COSPAR/NORAD ID).",
+ "type": "object",
+ "required": ["designator", "name", "satellite_type_code", "status"],
+ "additionalProperties": false,
+ "properties": {
+ "designator": {
+ "type": "string",
+ "description": "Международное обозначение (COSPAR designator), например 1998-067A",
+ "pattern": "^[0-9]{4}-[0-9]{3}[A-Z]+$"
+ },
+ "norad_id": {
+ "type": "integer",
+ "description": "NORAD catalog number",
+ "minimum": 1
+ },
+ "name": {
+ "type": "object",
+ "x-localized": true,
+ "description": "Наименование аппарата",
+ "patternProperties": {
+ "^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
+ },
+ "required": ["ru-RU"]
+ },
+ "operator": {
+ "type": "object",
+ "x-localized": true,
+ "description": "Оператор/владелец КА",
+ "patternProperties": {
+ "^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
+ }
+ },
+ "country": {
+ "type": "string",
+ "description": "ISO 3166-1 alpha-2 код страны-оператора",
+ "pattern": "^[A-Z]{2}$"
+ },
+ "satellite_type_code": {
+ "type": "string",
+ "description": "FK на satellite_type.code"
+ },
+ "mass_kg": {
+ "type": "number",
+ "description": "Масса КА на старте, кг",
+ "minimum": 0
+ },
+ "launch_date": {
+ "type": "string",
+ "format": "date"
+ },
+ "decay_date": {
+ "type": "string",
+ "format": "date",
+ "description": "Дата схода с орбиты (если применимо)"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["PLANNED", "ACTIVE", "INACTIVE", "DECAYED", "LOST"]
+ },
+ "orbit": {
+ "type": "object",
+ "description": "Параметры орбиты на эпоху",
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["LEO", "MEO", "GEO", "HEO", "SSO", "OTHER"]
+ },
+ "semimajor_axis_km": { "type": "number", "minimum": 0 },
+ "eccentricity": { "type": "number", "minimum": 0, "maximum": 1 },
+ "inclination_deg": { "type": "number", "minimum": 0, "maximum": 180 },
+ "raan_deg": { "type": "number", "minimum": 0, "maximum": 360 },
+ "argument_of_perigee_deg": { "type": "number", "minimum": 0, "maximum": 360 },
+ "epoch": { "type": "string", "format": "date-time" }
+ }
+ },
+ "spectrum_bands": {
+ "type": "array",
+ "description": "Спектральные диапазоны полезной нагрузки",
+ "items": {
+ "type": "string",
+ "enum": ["VIS", "NIR", "SWIR", "MWIR", "LWIR", "TIR", "L_BAND", "S_BAND", "C_BAND", "X_BAND", "KU_BAND", "KA_BAND"]
+ },
+ "uniqueItems": true
+ },
+ "mission": {
+ "type": "object",
+ "x-localized": true,
+ "description": "Краткое описание миссии",
+ "patternProperties": {
+ "^[a-z]{2}-[A-Z]{2}$": { "type": "string" }
+ }
+ }
+ }
+}
diff --git a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java
index 6b19ac1..1b7c85a 100644
--- a/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java
+++ b/ordinis-validation/src/main/java/cloud/nstart/terravault/ordinis/validation/RecordValidator.java
@@ -63,7 +63,8 @@ public class RecordValidator {
/**
* Рекурсивный обход schema. Для каждого {@code properties[name]} с
- * {@code x-localized: true} — проверяет значение в data.
+ * {@code x-localized: true} — проверяет значение в data, учитывая required
+ * массив родительской схемы (optional поля не требуют значения).
*/
private void walkLocalizedFields(
JsonNode schema,
@@ -78,6 +79,12 @@ public class RecordValidator {
JsonNode properties = schema.get("properties");
if (properties == null || !properties.isObject()) return;
+ Set requiredFields = new HashSet<>();
+ JsonNode required = schema.get("required");
+ if (required != null && required.isArray()) {
+ required.forEach(n -> { if (n.isTextual()) requiredFields.add(n.asText()); });
+ }
+
Iterator> it = properties.fields();
while (it.hasNext()) {
Map.Entry entry = it.next();
@@ -86,10 +93,11 @@ public class RecordValidator {
String fieldPath = path.isEmpty() ? "/" + fieldName : path + "/" + fieldName;
JsonNode dataField = data == null ? null : data.get(fieldName);
+ boolean fieldIsRequired = requiredFields.contains(fieldName);
JsonNode xLocalized = fieldSchema.get("x-localized");
if (xLocalized != null && xLocalized.isBoolean() && xLocalized.asBoolean()) {
- validateLocalizedValue(fieldPath, dataField, supported, defaultLocale, errors);
+ validateLocalizedValue(fieldPath, dataField, fieldIsRequired, supported, defaultLocale, errors);
} else if (fieldSchema.has("properties")) {
walkLocalizedFields(fieldSchema, dataField, fieldPath, supported, defaultLocale, errors);
}
@@ -99,13 +107,16 @@ public class RecordValidator {
private void validateLocalizedValue(
String path,
JsonNode value,
+ boolean isRequired,
Set supported,
String defaultLocale,
List errors) {
if (value == null || value.isNull()) {
- errors.add(new ValidationError(path, "x-localized.missing",
- "Localized field is required (must be object with locale keys)"));
+ if (isRequired) {
+ errors.add(new ValidationError(path, "x-localized.missing",
+ "Localized field is required (must be object with locale keys)"));
+ }
return;
}
if (!value.isObject()) {