All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.apicurio.registry.rules.compatibility.protobuf.ProtobufCompatibilityCheckerLibrary Maven / Gradle / Ivy

There is a newer version: 3.0.3
Show newest version
package io.apicurio.registry.rules.compatibility.protobuf;

import com.squareup.wire.Syntax;
import com.squareup.wire.schema.Field;
import com.squareup.wire.schema.internal.parser.EnumConstantElement;
import com.squareup.wire.schema.internal.parser.FieldElement;
import io.apicurio.registry.protobuf.ProtobufDifference;
import io.apicurio.registry.utils.protobuf.schema.ProtobufFile;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
 * Provides compatibility validation functions for changes between two versions of a Protobuf schema document.
 *
 * @see Protolock
 */
public class ProtobufCompatibilityCheckerLibrary {
    // TODO https://github.com/square/wire/issues/797 RFE: capture EnumElement reserved info

    private final ProtobufFile fileBefore;
    private final ProtobufFile fileAfter;

    public ProtobufCompatibilityCheckerLibrary(ProtobufFile fileBefore, ProtobufFile fileAfter) {
        this.fileBefore = fileBefore;
        this.fileAfter = fileAfter;
    }

    public boolean validate() {
        return findDifferences().isEmpty();
    }

    public List findDifferences() {
        List totalIssues = new ArrayList<>();
        totalIssues.addAll(checkNoUsingReservedFields());
        totalIssues.addAll(checkNoRemovingReservedFields());
        totalIssues.addAll(checkNoRemovingFieldsWithoutReserve());
        totalIssues.addAll(checkNoChangingFieldIDs());
        totalIssues.addAll(checkNoChangingFieldTypes());
        totalIssues.addAll(checkNoChangingFieldNames());
        totalIssues.addAll(checkNoRemovingServiceRPCs());
        totalIssues.addAll(checkNoChangingRPCSignature());
        if (Syntax.PROTO_2.equals(fileBefore.getSyntax())) {
            totalIssues.addAll(checkRequiredFields());
        }
        return totalIssues;
    }

    /**
     * Determine if any message's previously reserved fields or IDs are now being used as part of the same
     * message.
     * 

* Note: TODO can't currently validate enum reserved fields, as the parser doesn't capture those. * * @return differences list */ public List checkNoUsingReservedFields() { List issues = new ArrayList<>(); Map> reservedFields = fileBefore.getReservedFields(); Map> nonReservedFields = fileAfter.getNonReservedFields(); for (Map.Entry> entry : nonReservedFields.entrySet()) { Set old = reservedFields.get(entry.getKey()); if (old != null) { Set intersection = new HashSet<>(entry.getValue()); intersection.retainAll(old); if (!intersection.isEmpty()) { issues.add(ProtobufDifference .from(String.format("Conflict of reserved %d fields, message %s", intersection.size(), entry.getKey()))); } } } return issues; } /** * Determine if any reserved field has been removed. *

* Note: TODO can't currently validate enum reserved fields, as the parser doesn't capture those. * * @return differences list */ public List checkNoRemovingReservedFields() { List issues = new ArrayList<>(); Map> before = fileBefore.getReservedFields(); Map> after = fileAfter.getReservedFields(); for (Map.Entry> entry : before.entrySet()) { Set afterKeys = after.get(entry.getKey()); if (afterKeys != null) { Set intersection = new HashSet<>(entry.getValue()); intersection.retainAll(afterKeys); int diff = entry.getValue().size() - intersection.size(); if (diff != 0) { issues.add(ProtobufDifference.from(String .format("%d reserved fields were removed, message %s", diff, entry.getKey()))); } } else { issues.add( ProtobufDifference.from(String.format("%d reserved fields were removed, message %s", entry.getValue().size(), entry.getKey()))); } } return issues; } /** * Determine if any field has been removed without a corresponding reservation of that field name or ID. *

* Note: TODO can't currently validate enum reserved fields, as the parser doesn't capture those. * * @return differences list */ public List checkNoRemovingFieldsWithoutReserve() { List issues = new ArrayList<>(); Map> before = fileBefore.getFieldMap(); Map> after = fileAfter.getFieldMap(); Map> afterReservedFields = fileAfter.getReservedFields(); Map> afterNonreservedFields = fileAfter.getNonReservedFields(); for (Map.Entry> entry : before.entrySet()) { Set removedFieldNames = new HashSet<>(entry.getValue().keySet()); Map updated = after.get(entry.getKey()); if (updated != null) { removedFieldNames.removeAll(updated.keySet()); } int issuesCount = 0; // count once for each non-reserved field name Set reserved = afterReservedFields.getOrDefault(entry.getKey(), Collections.emptySet()); Set nonreserved = afterNonreservedFields.getOrDefault(entry.getKey(), Collections.emptySet()); Set nonReservedRemovedFieldNames = new HashSet<>(removedFieldNames); nonReservedRemovedFieldNames.removeAll(reserved); issuesCount += nonReservedRemovedFieldNames.size(); // count again for each non-reserved field id for (FieldElement fieldElement : entry.getValue().values()) { if (removedFieldNames.contains(fieldElement.getName()) && !(reserved.contains(fieldElement.getTag()) || nonreserved.contains(fieldElement.getTag()))) { issuesCount++; } } if (issuesCount > 0) { issues.add(ProtobufDifference.from(String.format( "%d fields removed without reservation, message %s", issuesCount, entry.getKey()))); } } return issues; } /** * Determine if any field ID number has been changed. * * @return differences list */ public List checkNoChangingFieldIDs() { List issues = new ArrayList<>(); Map> before = fileBefore.getFieldMap(); Map> after = fileAfter.getFieldMap(); for (Map.Entry> entry : before.entrySet()) { Map afterMap = after.get(entry.getKey()); if (afterMap != null) { for (Map.Entry beforeKV : entry.getValue().entrySet()) { FieldElement afterFE = afterMap.get(beforeKV.getKey()); if (afterFE != null && beforeKV.getValue().getTag() != afterFE.getTag()) { issues.add(ProtobufDifference.from(String.format( "Conflict, field id changed, message %s , before: %s , after %s", entry.getKey(), beforeKV.getValue().getTag(), afterFE.getTag()))); } } } } Map> beforeEnum = fileBefore.getEnumFieldMap(); Map> afterEnum = fileAfter.getEnumFieldMap(); for (Map.Entry> entry : beforeEnum.entrySet()) { Map afterMap = afterEnum.get(entry.getKey()); if (afterMap != null) { for (Map.Entry beforeKV : entry.getValue().entrySet()) { EnumConstantElement afterECE = afterMap.get(beforeKV.getKey()); if (afterECE != null && beforeKV.getValue().getTag() != afterECE.getTag()) { issues.add(ProtobufDifference.from(String.format( "Conflict, field id changed, message %s , before: %s , after %s", entry.getKey(), beforeKV.getValue().getTag(), afterECE.getTag()))); } } } } return issues; } /** * Determine if any field type has been changed. * * @return differences list */ public List checkNoChangingFieldTypes() { List issues = new ArrayList<>(); Map> before = fileBefore.getFieldMap(); Map> after = fileAfter.getFieldMap(); for (Map.Entry> entry : before.entrySet()) { Map afterMap = after.get(entry.getKey()); if (afterMap != null) { for (Map.Entry beforeKV : entry.getValue().entrySet()) { FieldElement afterFE = afterMap.get(beforeKV.getKey()); if (afterFE != null) { String beforeType = normalizeType(fileBefore, beforeKV.getValue().getType()); String afterType = normalizeType(fileAfter, afterFE.getType()); if (afterFE != null && !beforeType.equals(afterType)) { issues.add(ProtobufDifference.from(String.format( "Field type changed, message %s , before: %s , after %s", entry.getKey(), beforeKV.getValue().getType(), afterFE.getType()))); } if (afterFE != null && !Objects.equals(beforeKV.getValue().getLabel(), afterFE.getLabel())) { issues.add(ProtobufDifference.from(String.format( "Field label changed, message %s , before: %s , after %s", entry.getKey(), beforeKV.getValue().getLabel(), afterFE.getLabel()))); } } } } } return issues; } private String normalizeType(ProtobufFile file, String type) { if (type != null && type.startsWith(".")) { // it's fully qualified String nodot = type.substring(1); if (file.getPackageName() != null && nodot.startsWith(file.getPackageName())) { // it's fully qualified but it's a message in the same .proto file return nodot.substring(file.getPackageName().length() + 1); } return nodot; } return type; } /** * Determine if any message's previous fields have been renamed. * * @return differences list */ public List checkNoChangingFieldNames() { List issues = new ArrayList<>(); Map> before = new HashMap<>(fileBefore.getFieldsById()); before.putAll(fileBefore.getEnumFieldsById()); Map> after = new HashMap<>(fileAfter.getFieldsById()); after.putAll(fileAfter.getEnumFieldsById()); for (Map.Entry> entry : before.entrySet()) { Map afterMap = after.get(entry.getKey()); if (afterMap != null) { for (Map.Entry beforeKV : entry.getValue().entrySet()) { String nameAfter = afterMap.get(beforeKV.getKey()); if (!beforeKV.getValue().equals(nameAfter)) { issues.add(ProtobufDifference .from(String.format("Field name changed, message %s , before: %s , after %s", entry.getKey(), beforeKV.getValue(), nameAfter))); } } } } return issues; } /** * Determine if any RPCs provided by a Service have been removed. * * @return differences list */ public List checkNoRemovingServiceRPCs() { List issues = new ArrayList<>(); Map> before = fileBefore.getServiceRPCnames(); Map> after = fileAfter.getServiceRPCnames(); for (Map.Entry> entry : before.entrySet()) { Set afterSet = after.get(entry.getKey()); Set diff = new HashSet<>(entry.getValue()); if (afterSet != null) { diff.removeAll(afterSet); } if (diff.size() > 0) { issues.add(ProtobufDifference.from( String.format("%d rpc services removed, message %s", diff.size(), entry.getKey()))); } } return issues; } /** * Determine if any RPC signature has been changed while using the same name. * * @return differences list */ public List checkNoChangingRPCSignature() { List issues = new ArrayList<>(); Map> before = fileBefore.getServiceRPCSignatures(); Map> after = fileAfter.getServiceRPCSignatures(); for (Map.Entry> entry : before.entrySet()) { Map afterMap = after.get(entry.getKey()); if (afterMap != null) { for (Map.Entry beforeKV : entry.getValue().entrySet()) { String afterSig = afterMap.get(beforeKV.getKey()); if (!beforeKV.getValue().equals(afterSig)) { issues.add(ProtobufDifference.from(String.format( "rpc service signature changed, message %s , before %s , after %s", entry.getKey(), beforeKV.getValue(), afterSig))); } } } } return issues; } /** * Determine if any required field has been added in the new version. * * @return differences list */ public List checkRequiredFields() { List issues = new ArrayList<>(); Map> before = fileBefore.getFieldMap(); Map> after = fileAfter.getFieldMap(); for (Map.Entry> entry : after.entrySet()) { Map beforeMap = before.get(entry.getKey()); if (beforeMap != null) { for (Map.Entry afterKV : entry.getValue().entrySet()) { FieldElement afterSig = beforeMap.get(afterKV.getKey()); if (afterSig == null && afterKV.getValue().getLabel() != null && afterKV.getValue().getLabel().equals(Field.Label.REQUIRED)) { issues.add(ProtobufDifference.from( String.format("required field added in new version, message %s, after %s", entry.getKey(), afterKV.getValue()))); } } } } return issues; } }