Initial JSONSchema specification for Asset Certification (#18392)

* Initial JSONSchema specification for Asset Certification

* Add backend logic to updateCertification

* Add Tests for Asset Certification

* Update certification config and set defaults

* Fix Checkstyle

* Fix Tests

* Fix checkstyle
This commit is contained in:
IceS2 2024-10-30 11:40:39 +01:00 committed by GitHub
parent 289404748a
commit 57c22b5fbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 325 additions and 1 deletions

View File

@ -122,6 +122,7 @@ public final class Entity {
public static final String FIELD_STYLE = "style";
public static final String FIELD_LIFE_CYCLE = "lifeCycle";
public static final String FIELD_CERTIFICATION = "certification";
public static final String FIELD_DISABLED = "disabled";

View File

@ -65,6 +65,7 @@ import org.openmetadata.schema.auth.PasswordResetToken;
import org.openmetadata.schema.auth.PersonalAccessToken;
import org.openmetadata.schema.auth.RefreshToken;
import org.openmetadata.schema.auth.TokenType;
import org.openmetadata.schema.configuration.AssetCertificationSettings;
import org.openmetadata.schema.dataInsight.DataInsightChart;
import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChart;
import org.openmetadata.schema.dataInsight.kpi.Kpi;
@ -4910,6 +4911,8 @@ public interface CollectionDAO {
.readValue(json, String.class);
case PROFILER_CONFIGURATION -> JsonUtils.readValue(json, ProfilerConfiguration.class);
case SEARCH_SETTINGS -> JsonUtils.readValue(json, SearchSettings.class);
case ASSET_CERTIFICATION_SETTINGS -> JsonUtils.readValue(
json, AssetCertificationSettings.class);
default -> throw new IllegalArgumentException("Invalid Settings Type " + configType);
};
settings.setConfigValue(value);

View File

@ -29,6 +29,7 @@ import static org.openmetadata.schema.utils.EntityInterfaceUtil.quoteName;
import static org.openmetadata.service.Entity.ADMIN_USER_NAME;
import static org.openmetadata.service.Entity.DATA_PRODUCT;
import static org.openmetadata.service.Entity.DOMAIN;
import static org.openmetadata.service.Entity.FIELD_CERTIFICATION;
import static org.openmetadata.service.Entity.FIELD_CHILDREN;
import static org.openmetadata.service.Entity.FIELD_DATA_PRODUCTS;
import static org.openmetadata.service.Entity.FIELD_DELETED;
@ -78,8 +79,11 @@ import com.networknt.schema.JsonSchema;
import com.networknt.schema.ValidationMessage;
import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Period;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;
@ -121,12 +125,14 @@ import org.openmetadata.schema.api.VoteRequest;
import org.openmetadata.schema.api.VoteRequest.VoteType;
import org.openmetadata.schema.api.feed.ResolveTask;
import org.openmetadata.schema.api.teams.CreateTeam;
import org.openmetadata.schema.configuration.AssetCertificationSettings;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.entity.feed.Suggestion;
import org.openmetadata.schema.entity.teams.Team;
import org.openmetadata.schema.entity.teams.User;
import org.openmetadata.schema.system.EntityError;
import org.openmetadata.schema.type.ApiStatus;
import org.openmetadata.schema.type.AssetCertification;
import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.schema.type.Column;
@ -235,6 +241,7 @@ public abstract class EntityRepository<T extends EntityInterface> {
@Getter protected final boolean supportsOwners;
@Getter protected final boolean supportsStyle;
@Getter protected final boolean supportsLifeCycle;
@Getter protected final boolean supportsCertification;
protected final boolean supportsFollower;
protected final boolean supportsExtension;
protected final boolean supportsVotes;
@ -328,6 +335,11 @@ public abstract class EntityRepository<T extends EntityInterface> {
this.patchFields.addField(allowedFields, FIELD_LIFE_CYCLE);
this.putFields.addField(allowedFields, FIELD_LIFE_CYCLE);
}
this.supportsCertification = allowedFields.contains(FIELD_CERTIFICATION);
if (supportsCertification) {
this.patchFields.addField(allowedFields, FIELD_CERTIFICATION);
this.putFields.addField(allowedFields, FIELD_CERTIFICATION);
}
Map<String, Pair<Boolean, BiConsumer<List<T>, Fields>>> fieldSupportMap = new HashMap<>();
@ -2627,6 +2639,7 @@ public abstract class EntityRepository<T extends EntityInterface> {
updateReviewers();
updateStyle();
updateLifeCycle();
updateCertification();
entitySpecificUpdate();
}
}
@ -2929,6 +2942,53 @@ public abstract class EntityRepository<T extends EntityInterface> {
recordChange(FIELD_LIFE_CYCLE, origLifeCycle, updatedLifeCycle, true);
}
private void updateCertification() {
if (!supportsCertification) {
return;
}
AssetCertification origCertification = original.getCertification();
AssetCertification updatedCertification = updated.getCertification();
if (origCertification == updatedCertification || updatedCertification == null) return;
SystemRepository systemRepository = Entity.getSystemRepository();
AssetCertificationSettings assetCertificationSettings =
systemRepository.getAssetCertificationSettings();
String certificationLabel = updatedCertification.getTagLabel().getTagFQN();
validateCertification(certificationLabel, assetCertificationSettings);
long certificationDate = System.currentTimeMillis();
updatedCertification.setAppliedDate(certificationDate);
LocalDateTime nowDateTime =
LocalDateTime.ofInstant(Instant.ofEpochMilli(certificationDate), ZoneOffset.UTC);
Period datePeriod = Period.parse(assetCertificationSettings.getValidityPeriod());
LocalDateTime targetDateTime = nowDateTime.plus(datePeriod);
updatedCertification.setExpiryDate(targetDateTime.toInstant(ZoneOffset.UTC).toEpochMilli());
recordChange(FIELD_CERTIFICATION, origCertification, updatedCertification, true);
}
private void validateCertification(
String certificationLabel, AssetCertificationSettings assetCertificationSettings) {
if (Optional.ofNullable(assetCertificationSettings).isEmpty()) {
throw new IllegalArgumentException(
"Certification is not configured. Please configure the Classification used for Certification in the Settings.");
} else {
String allowedClassification = assetCertificationSettings.getAllowedClassification();
String[] fqnParts = FullyQualifiedName.split(certificationLabel);
String parentFqn = FullyQualifiedName.getParentFQN(fqnParts);
if (!allowedClassification.equals(parentFqn)) {
throw new IllegalArgumentException(
String.format(
"Invalid Classification: %s is not valid for Certification.",
certificationLabel));
}
}
}
public final boolean updateVersion(Double oldVersion) {
Double newVersion = oldVersion;
if (majorVersionChange) {

View File

@ -9,6 +9,7 @@ import com.slack.api.bolt.model.builtin.DefaultInstaller;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import javax.json.JsonPatch;
import javax.json.JsonValue;
import javax.ws.rs.core.Response;
@ -16,6 +17,7 @@ import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.jdbi.v3.sqlobject.transaction.Transaction;
import org.openmetadata.api.configuration.UiThemePreference;
import org.openmetadata.schema.configuration.AssetCertificationSettings;
import org.openmetadata.schema.email.SmtpSettings;
import org.openmetadata.schema.entity.services.ingestionPipelines.PipelineServiceClientResponse;
import org.openmetadata.schema.security.client.OpenMetadataJWTClientConfig;
@ -110,6 +112,15 @@ public class SystemRepository {
return null;
}
public AssetCertificationSettings getAssetCertificationSettings() {
Optional<Settings> oAssetCertificationSettings =
Optional.ofNullable(getConfigWithKey(SettingsType.ASSET_CERTIFICATION_SETTINGS.value()));
return oAssetCertificationSettings
.map(settings -> (AssetCertificationSettings) settings.getConfigValue())
.orElse(null);
}
public Settings getEmailConfigInternal() {
try {
Settings setting = dao.getConfigWithKey(SettingsType.EMAIL_CONFIGURATION.value());

View File

@ -13,6 +13,7 @@
package org.openmetadata.service.resources.settings;
import static org.openmetadata.schema.settings.SettingsType.ASSET_CERTIFICATION_SETTINGS;
import static org.openmetadata.schema.settings.SettingsType.CUSTOM_UI_THEME_PREFERENCE;
import static org.openmetadata.schema.settings.SettingsType.EMAIL_CONFIGURATION;
import static org.openmetadata.schema.settings.SettingsType.LOGIN_CONFIGURATION;
@ -30,6 +31,7 @@ import org.openmetadata.api.configuration.ThemeConfiguration;
import org.openmetadata.api.configuration.UiThemePreference;
import org.openmetadata.schema.api.configuration.LoginConfiguration;
import org.openmetadata.schema.api.searcg.SearchSettings;
import org.openmetadata.schema.configuration.AssetCertificationSettings;
import org.openmetadata.schema.email.SmtpSettings;
import org.openmetadata.schema.settings.Settings;
import org.openmetadata.schema.settings.SettingsType;
@ -124,6 +126,20 @@ public class SettingsCache {
.withConfigValue(new SearchSettings().withEnableAccessControl(false));
systemRepository.createNewSetting(setting);
}
// Initialise Certification Settings
Settings certificationSettings =
systemRepository.getConfigWithKey(ASSET_CERTIFICATION_SETTINGS.toString());
if (certificationSettings == null) {
Settings setting =
new Settings()
.withConfigType(ASSET_CERTIFICATION_SETTINGS)
.withConfigValue(
new AssetCertificationSettings()
.withAllowedClassification("Certification")
.withValidityPeriod("P30D"));
systemRepository.createNewSetting(setting);
}
}
public static <T> T getSetting(SettingsType settingName, Class<T> clazz) {

View File

@ -0,0 +1,31 @@
{
"createClassification": {
"name": "Certification",
"description": "Certifying Data Asset will provide the users with a clear idea of how reliable a Data Asset is.",
"provider": "system",
"mutuallyExclusive": "true"
},
"createTags": [
{
"name": "Bronze",
"description": "Bronze certified Data Asset.",
"style": {
"color": "#CD7F32"
}
},
{
"name": "Silver",
"description": "Silver certified Data Asset.",
"style": {
"color": "#C0C0C0"
}
},
{
"name": "Gold",
"description": "Gold certified Data Asset.",
"style": {
"color": "#FFD700"
}
}
]
}

View File

@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.openmetadata.common.utils.CommonUtil.listOf;
import static org.openmetadata.common.utils.CommonUtil.listOrEmpty;
import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
@ -128,6 +129,7 @@ import org.openmetadata.schema.api.feed.CreateThread;
import org.openmetadata.schema.api.teams.CreateTeam;
import org.openmetadata.schema.api.teams.CreateTeam.TeamType;
import org.openmetadata.schema.api.tests.CreateTestSuite;
import org.openmetadata.schema.configuration.AssetCertificationSettings;
import org.openmetadata.schema.dataInsight.DataInsightChart;
import org.openmetadata.schema.dataInsight.type.KpiTarget;
import org.openmetadata.schema.entities.docStore.Document;
@ -155,11 +157,14 @@ import org.openmetadata.schema.entity.teams.User;
import org.openmetadata.schema.entity.type.Category;
import org.openmetadata.schema.entity.type.CustomProperty;
import org.openmetadata.schema.entity.type.Style;
import org.openmetadata.schema.settings.Settings;
import org.openmetadata.schema.settings.SettingsType;
import org.openmetadata.schema.tests.TestDefinition;
import org.openmetadata.schema.tests.TestSuite;
import org.openmetadata.schema.type.AccessDetails;
import org.openmetadata.schema.type.AnnouncementDetails;
import org.openmetadata.schema.type.ApiStatus;
import org.openmetadata.schema.type.AssetCertification;
import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.schema.type.Column;
@ -178,6 +183,7 @@ import org.openmetadata.service.Entity;
import org.openmetadata.service.OpenMetadataApplicationTest;
import org.openmetadata.service.exception.CatalogExceptionMessage;
import org.openmetadata.service.jdbi3.EntityRepository.EntityUpdater;
import org.openmetadata.service.jdbi3.SystemRepository;
import org.openmetadata.service.resources.apis.APICollectionResourceTest;
import org.openmetadata.service.resources.bots.BotResourceTest;
import org.openmetadata.service.resources.databases.TableResourceTest;
@ -209,6 +215,7 @@ import org.openmetadata.service.resources.teams.*;
import org.openmetadata.service.search.models.IndexMapping;
import org.openmetadata.service.security.SecurityUtil;
import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.JsonUtils;
import org.openmetadata.service.util.ResultList;
import org.openmetadata.service.util.TestUtils;
@ -247,6 +254,7 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
protected final boolean supportsDataProducts;
protected final boolean supportsExperts;
protected final boolean supportsReviewers;
protected final boolean supportsCertification;
public static final String DATA_STEWARD_ROLE_NAME = "DataSteward";
public static final String DATA_CONSUMER_ROLE_NAME = "DataConsumer";
@ -454,6 +462,7 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
this.supportsDataProducts = allowedFields.contains(FIELD_DATA_PRODUCTS);
this.supportsExperts = allowedFields.contains(FIELD_EXPERTS);
this.supportsReviewers = allowedFields.contains(FIELD_REVIEWERS);
this.supportsCertification = allowedFields.contains(FIELD_CERTIFICATION);
}
@BeforeAll
@ -2432,6 +2441,100 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
updateLifeCycle(json, entity, lifeCycle1, lifeCycle);
}
@Test
void postPutPatch_entityCertification(TestInfo test) throws IOException {
if (!supportsCertification) {
return;
}
// Create an entity without lifeCycle
T entity =
createEntity(
createRequest(getEntityName(test), "description", null, null), ADMIN_AUTH_HEADERS);
// Create Tag
TagResourceTest tagResourceTest = new TagResourceTest();
Tag certificationTag =
tagResourceTest.createEntity(tagResourceTest.createRequest(test, 0), ADMIN_AUTH_HEADERS);
TagLabel certificationLabel = EntityUtil.toTagLabel(certificationTag);
// Add certification using PATCH request
String json = JsonUtils.pojoToJson(entity);
AssetCertification certification = new AssetCertification().withTagLabel(certificationLabel);
entity.setCertification(certification);
try {
patchEntity(entity.getId(), json, entity, ADMIN_AUTH_HEADERS);
fail("Expected an exception to be thrown: Certification is not configured yet.");
} catch (HttpResponseException e) {
assertEquals(e.getStatusCode(), 400);
}
// Configure Certification Settings
String[] fqnParts = FullyQualifiedName.split(certificationLabel.getTagFQN());
String classification = FullyQualifiedName.getParentFQN(fqnParts);
AssetCertificationSettings certificationSettings =
new AssetCertificationSettings()
.withAllowedClassification(classification)
.withValidityPeriod("P30D");
SystemRepository systemRepository = Entity.getSystemRepository();
systemRepository.updateSetting(
new Settings()
.withConfigType(SettingsType.ASSET_CERTIFICATION_SETTINGS)
.withConfigValue(certificationSettings));
T patchedEntity = patchEntity(entity.getId(), json, entity, ADMIN_AUTH_HEADERS);
assertEquals(
patchedEntity.getCertification().getTagLabel().getTagFQN(), certificationLabel.getTagFQN());
assertEquals(
patchedEntity.getCertification().getAppliedDate(), System.currentTimeMillis(), 10 * 1000);
assertEquals(
(double)
(patchedEntity.getCertification().getExpiryDate()
- patchedEntity.getCertification().getAppliedDate()),
30D * 24 * 60 * 60 * 1000,
10 * 1000);
// Create Second Tag
Tag newCertificationTag =
tagResourceTest.createEntity(tagResourceTest.createRequest(test, 1), ADMIN_AUTH_HEADERS);
TagLabel newCertificationLabel = EntityUtil.toTagLabel(newCertificationTag);
// Configure Certification Settings
String[] newFqnParts = FullyQualifiedName.split(newCertificationLabel.getTagFQN());
String newClassification = FullyQualifiedName.getParentFQN(newFqnParts);
AssetCertificationSettings newCertificationSettings =
new AssetCertificationSettings()
.withAllowedClassification(newClassification)
.withValidityPeriod("P60D");
systemRepository.updateSetting(
new Settings()
.withConfigType(SettingsType.ASSET_CERTIFICATION_SETTINGS)
.withConfigValue(newCertificationSettings));
String newJson = JsonUtils.pojoToJson(entity);
AssetCertification newCertification =
new AssetCertification().withTagLabel(newCertificationLabel);
entity.setCertification(newCertification);
T newPatchedEntity = patchEntity(entity.getId(), newJson, entity, ADMIN_AUTH_HEADERS);
assertEquals(
newPatchedEntity.getCertification().getTagLabel().getTagFQN(),
newCertificationLabel.getTagFQN());
assertEquals(
newPatchedEntity.getCertification().getAppliedDate(),
System.currentTimeMillis(),
10 * 1000);
assertEquals(
(double)
(newPatchedEntity.getCertification().getExpiryDate()
- newPatchedEntity.getCertification().getAppliedDate()),
60D * 24 * 60 * 60 * 1000,
10 * 1000);
}
private T updateLifeCycle(
String json, T entity, LifeCycle newLifeCycle, LifeCycle expectedLifeCycle)
throws HttpResponseException {

View File

@ -108,6 +108,10 @@ public interface EntityInterface {
return null;
}
default AssetCertification getCertification() {
return null;
}
void setId(UUID id);
void setDescription(String description);
@ -178,6 +182,10 @@ public interface EntityInterface {
/* no-op implementation to be overridden */
}
default void setCertification(AssetCertification certification) {
/* no-op implementation to be overridden */
}
<T extends EntityInterface> T withHref(URI href);
@JsonIgnore

View File

@ -0,0 +1,20 @@
{
"$id": "https://open-metadata.org/schema/configuration/assetCertificationSettings.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AssetCertificationSettings",
"description": "This schema defines the Asset Certification Settings.",
"type": "object",
"javaType": "org.openmetadata.schema.configuration.AssetCertificationSettings",
"properties": {
"allowedClassification": {
"type": "string",
"description": "Classification that can be used for certifications."
},
"validityPeriod": {
"type": "string",
"description": "ISO 8601 duration for the validity period."
}
},
"required": ["allowedClassification", "validityPeriod"],
"additionalProperties": false
}

View File

@ -105,6 +105,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -167,6 +167,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -155,6 +155,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -189,6 +189,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -143,6 +143,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -166,6 +166,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -121,6 +121,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -117,6 +117,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -195,6 +195,9 @@
"extension": {
"description": "Entity extension data with custom attributes added to the entity.",
"$ref": "../../type/basic.json#/definitions/entityExtension"
},
"certification": {
"$ref": "../../type/assetCertification.json"
}
},
"required": ["id", "name"],

View File

@ -285,6 +285,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -271,6 +271,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -248,6 +248,9 @@
"description": "Life Cycle of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -158,6 +158,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -1121,6 +1121,9 @@
"description": "Life Cycle of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -174,6 +174,9 @@
"description": "Life Cycle properties of the entity",
"$ref": "../../type/lifeCycle.json"
},
"certification": {
"$ref": "../../type/assetCertification.json"
},
"sourceHash": {
"description": "Source hash of the entity",
"type": "string",

View File

@ -30,7 +30,8 @@
"slackInstaller",
"slackState",
"profilerConfiguration",
"searchSettings"
"searchSettings",
"assetCertificationSettings"
]
}
},
@ -76,6 +77,9 @@
},
{
"$ref": "../configuration/searchSettings.json"
},
{
"$ref": "../configuration/assetCertificationSettings.json"
}
]
}

View File

@ -0,0 +1,22 @@
{ "$id": "https://open-metadata.org/schema/type/assetCertification.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AssetCertification",
"description": "Defines the Asset Certification schema.",
"javaType": "org.openmetadata.schema.type.AssetCertification",
"type": "object",
"properties": {
"tagLabel": {
"$ref": "./tagLabel.json"
},
"appliedDate": {
"description": "The date when the certification was applied.",
"$ref": "basic.json#/definitions/timestamp"
},
"expiryDate": {
"description": "The date when the certification expires.",
"$ref": "basic.json#/definitions/timestamp"
}
},
"required": ["tagLabel", "appliedDate", "expiryDate"],
"additionalProperties": false
}