diff --git a/src/main/java/com/chargebee/handlebar/NameFormatHelpers.java b/src/main/java/com/chargebee/handlebar/NameFormatHelpers.java index 75fb560..2c39d72 100644 --- a/src/main/java/com/chargebee/handlebar/NameFormatHelpers.java +++ b/src/main/java/com/chargebee/handlebar/NameFormatHelpers.java @@ -153,5 +153,12 @@ public CharSequence apply(final Object value, final Options options) { } return result.toString(); } + }, + + CONSTANT_CASE { + @Override + public CharSequence apply(final Object value, final Options options) { + return value.toString().toUpperCase().replace("-", "_"); + } } } diff --git a/src/main/java/com/chargebee/sdk/Language.java b/src/main/java/com/chargebee/sdk/Language.java index 27f73d8..dc81b95 100644 --- a/src/main/java/com/chargebee/sdk/Language.java +++ b/src/main/java/com/chargebee/sdk/Language.java @@ -59,6 +59,7 @@ private void initialise() throws IOException { handlebars.registerHelper("pascalCase", NameFormatHelpers.TO_PASCAL); handlebars.registerHelper( "operationNameToPascalCase", NameFormatHelpers.OPERATION_NAME_TO_PASCAL_CASE); + handlebars.registerHelper("constantCase", NameFormatHelpers.CONSTANT_CASE); handlebars.registerHelper( "snakeCaseToPascalCaseAndSingularize", diff --git a/src/main/java/com/chargebee/sdk/changelog/generators/ChangeLogGenerator.java b/src/main/java/com/chargebee/sdk/changelog/generators/ChangeLogGenerator.java index 3fc30b4..a2d6bf4 100644 --- a/src/main/java/com/chargebee/sdk/changelog/generators/ChangeLogGenerator.java +++ b/src/main/java/com/chargebee/sdk/changelog/generators/ChangeLogGenerator.java @@ -17,7 +17,6 @@ import com.google.common.base.CaseFormat; import io.swagger.v3.oas.models.media.ArraySchema; import io.swagger.v3.oas.models.media.Schema; - import java.io.IOException; import java.util.*; import java.util.function.Function; @@ -48,14 +47,18 @@ public FileOp generate(String output, Spec oldVersion, Spec newerVersion) throws List oldResources = filterResources(oldVersion.resources()); List newResources = filterResources(newerVersion.resources()); - ChangeLogSchema schema = buildChangeLogSchema(oldVersion, newerVersion, oldResources, newResources); + ChangeLogSchema schema = + buildChangeLogSchema(oldVersion, newerVersion, oldResources, newResources); String content = renderTemplate(schema); return new FileOp.WriteString("./", output + "CHANGELOG.md", content); } - private ChangeLogSchema buildChangeLogSchema(Spec oldVersion, Spec newerVersion, - List oldResources, List newResources) { + private ChangeLogSchema buildChangeLogSchema( + Spec oldVersion, + Spec newerVersion, + List oldResources, + List newResources) { ChangeLogSchema schema = new ChangeLogSchema(); schema.setNewResource(generateNewResources(oldResources, newResources)); @@ -70,48 +73,54 @@ private ChangeLogSchema buildChangeLogSchema(Spec oldVersion, Spec newerVersion, schema.setDeletedParams(generateDeletedParameters(oldResources, newResources)); schema.setDeletedEventType(generateDeletedEvents(oldVersion, newerVersion)); - schema.setNewEnumValues(generateNewEnumValues(oldResources, newResources, oldVersion, newerVersion)); - schema.setDeletedEnumValues(generateDeletedEnumValues(oldResources, newResources, oldVersion, newerVersion)); - schema.setParameterRequirementChangesValues(generateParameterRequirementChanges(oldResources, newResources)); + schema.setNewEnumValues( + generateNewEnumValues(oldResources, newResources, oldVersion, newerVersion)); + schema.setDeletedEnumValues( + generateDeletedEnumValues(oldResources, newResources, oldVersion, newerVersion)); + schema.setParameterRequirementChangesValues( + generateParameterRequirementChanges(oldResources, newResources)); return schema; } private String renderTemplate(ChangeLogSchema schema) throws IOException { - Map schemaMap = changeLogGenerator.getObjectMapper().convertValue(schema, Map.class); + Map schemaMap = + changeLogGenerator.getObjectMapper().convertValue(schema, Map.class); String content = changeLogTemplate.apply(schemaMap); - return content.replaceAll("(?m)^[ \t]*\r?\n([ \t]*\r?\n)+", "\n\n") - .replaceAll("^\\s+", ""); + return content.replaceAll("(?m)^[ \t]*\r?\n([ \t]*\r?\n)+", "\n\n").replaceAll("^\\s+", ""); } private List filterResources(List resources) { List hiddenResourceIds = List.of(changeLogGenerator.hiddenOverride); return resources.stream() - .filter(resource -> !hiddenResourceIds.contains(resource.id)) - .collect(Collectors.toList()); + .filter(resource -> !hiddenResourceIds.contains(resource.id)) + .collect(Collectors.toList()); } - private List generateNewResources(List oldResources, List newResources) { + private List generateNewResources( + List oldResources, List newResources) { Set existingResourceIds = extractResourceIds(oldResources); return newResources.stream() - .filter(resource -> !existingResourceIds.contains(resource.id)) - .map(this::formatNewResourceLine) - .distinct() - .collect(Collectors.toList()); + .filter(resource -> !existingResourceIds.contains(resource.id)) + .map(this::formatNewResourceLine) + .distinct() + .collect(Collectors.toList()); } private String formatNewResourceLine(Resource resource) { - return String.format("- [`%s`](%s) has been added.", - resource.name, getDocsUrlForResourceList(resource)); + return String.format( + "- [`%s`](%s) has been added.", resource.name, getDocsUrlForResourceList(resource)); } - private List generateNewActions(List oldResources, List newResources) { + private List generateNewActions( + List oldResources, List newResources) { Map> oldActionsByResource = buildActionMap(oldResources); Set lines = new LinkedHashSet<>(); for (Resource newResource : newResources) { - Set existingActions = oldActionsByResource.getOrDefault(newResource.id, Collections.emptySet()); + Set existingActions = + oldActionsByResource.getOrDefault(newResource.id, Collections.emptySet()); for (Action action : newResource.actions) { if (!existingActions.contains(action.name)) { @@ -124,117 +133,177 @@ private List generateNewActions(List oldResources, List generateNewAttributes(List oldResources, List newResources) { + private List generateNewAttributes( + List oldResources, List newResources) { Map> oldAttributesByResource = buildAttributeMap(oldResources); return newResources.stream() - .flatMap(resource -> findNewAttributes(resource, oldAttributesByResource)) - .distinct() - .collect(Collectors.toList()); + .flatMap(resource -> findNewAttributes(resource, oldAttributesByResource)) + .distinct() + .collect(Collectors.toList()); } - private Stream findNewAttributes(Resource resource, Map> oldAttributesByResource) { - Set existingAttributes = oldAttributesByResource.getOrDefault(resource.id, Collections.emptySet()); + private Stream findNewAttributes( + Resource resource, Map> oldAttributesByResource) { + Set existingAttributes = + oldAttributesByResource.getOrDefault(resource.id, Collections.emptySet()); return resource.attributes().stream() - .filter(attribute -> !existingAttributes.contains(attribute.name)) - .map(attribute -> formatNewAttributeLine(resource, attribute)); + .filter(attribute -> !existingAttributes.contains(attribute.name)) + .map(attribute -> formatNewAttributeLine(resource, attribute)); } private String formatNewAttributeLine(Resource resource, Attribute attribute) { - return String.format("- [`%s`](%s) has been added to [`%s`](%s).", - attribute.name, getDocsUrlForAttribute(resource, attribute), - resource.name, getDocsUrlForResourceList(resource)); - } - - private List generateNewParameters(List oldResources, List newResources) { - List queryParameterLines = generateParameterLines( + return String.format( + "- [`%s`](%s) has been added to [`%s`](%s).", + attribute.name, + getDocsUrlForAttribute(resource, attribute), + resource.name, + getDocsUrlForResourceList(resource)); + } + + private List generateNewParameters( + List oldResources, List newResources) { + List queryParameterLines = + generateParameterLines( oldResources, newResources, Action::queryParameters, QUERY_PARAMETER_TYPE); - List bodyParameterLines = generateParameterLines( + List bodyParameterLines = + generateParameterLines( oldResources, newResources, Action::requestBodyParameters, REQUEST_BODY_PARAMETER_TYPE); return Stream.concat(queryParameterLines.stream(), bodyParameterLines.stream()) - .collect(Collectors.toList()); + .collect(Collectors.toList()); } - private List generateParameterLines(List oldResources, List newResources, - Function> parameterExtractor, String parameterType) { - Map>> oldParametersByResource = buildParameterMap(oldResources, parameterExtractor); + private List generateParameterLines( + List oldResources, + List newResources, + Function> parameterExtractor, + String parameterType) { + Map>> oldParametersByResource = + buildParameterMap(oldResources, parameterExtractor); Set lines = new LinkedHashSet<>(); for (Resource newResource : newResources) { - Map> oldActionParameters = oldParametersByResource.getOrDefault( - newResource.id, Collections.emptyMap()); + Map> oldActionParameters = + oldParametersByResource.getOrDefault(newResource.id, Collections.emptyMap()); for (Action action : newResource.actions) { - Set existingParameters = oldActionParameters.getOrDefault(action.id, Collections.emptySet()); - findAndAddNewParameters(newResource, action, parameterExtractor.apply(action), - existingParameters, parameterType, lines); + Set existingParameters = + oldActionParameters.getOrDefault(action.id, Collections.emptySet()); + findAndAddNewParameters( + newResource, + action, + parameterExtractor.apply(action), + existingParameters, + parameterType, + lines); } } return new ArrayList<>(lines); } - private void findAndAddNewParameters(Resource resource, Action action, List parameters, - Set existingParameters, String parameterType, Set lines) { + private void findAndAddNewParameters( + Resource resource, + Action action, + List parameters, + Set existingParameters, + String parameterType, + Set lines) { for (Parameter parameter : parameters) { if (!existingParameters.contains(parameter.getName())) { lines.add(formatParameterLine(resource, action, parameter, parameterType, true)); } else if (parameter.schema.getProperties() != null) { - processNestedParameters(parameter.schema, parameter.getName(), existingParameters, - resource, action, parameterType, lines, true); + processNestedParameters( + parameter.schema, + parameter.getName(), + existingParameters, + resource, + action, + parameterType, + lines, + true); } } } - private void processNestedParameters(Schema schema, String path, Set existingParameters, - Resource resource, Action action, String parameterType, - Set lines, boolean isNew) { + private void processNestedParameters( + Schema schema, + String path, + Set existingParameters, + Resource resource, + Action action, + String parameterType, + Set lines, + boolean isNew) { if (schema.getProperties() == null) { return; } - schema.getProperties().forEach((key, value) -> { - String nestedPath = path + "." + key; - - if (!existingParameters.contains(nestedPath)) { - Parameter nestedParameter = new Parameter(nestedPath, (Schema) value); - lines.add(formatParameterLine(resource, action, nestedParameter, parameterType, isNew)); - } else { - processNestedParameters((Schema) value, nestedPath, existingParameters, - resource, action, parameterType, lines, isNew); - } - }); - } - - private String formatParameterLine(Resource resource, Action action, Parameter parameter, - String parameterType, boolean isAdded) { + schema + .getProperties() + .forEach( + (key, value) -> { + String nestedPath = path + "." + key; + + if (!existingParameters.contains(nestedPath)) { + Parameter nestedParameter = new Parameter(nestedPath, (Schema) value); + lines.add( + formatParameterLine(resource, action, nestedParameter, parameterType, isNew)); + } else { + processNestedParameters( + (Schema) value, + nestedPath, + existingParameters, + resource, + action, + parameterType, + lines, + isNew); + } + }); + } + + private String formatParameterLine( + Resource resource, + Action action, + Parameter parameter, + String parameterType, + boolean isAdded) { String actionVerb = isAdded ? "added" : "removed"; - return String.format("- [`%s`](%s) has been %s as %s to [`%s`](%s) in [`%s`](%s).", - parameter.getName(), getDocsUrlForParameter(resource, action, parameter), - actionVerb, parameterType, action.id, getDocsUrlForActions(resource, action), - resource.name, getDocsUrlForResourceList(resource)); + return String.format( + "- [`%s`](%s) has been %s as %s to [`%s`](%s) in [`%s`](%s).", + parameter.getName(), + getDocsUrlForParameter(resource, action, parameter), + actionVerb, + parameterType, + action.id, + getDocsUrlForActions(resource, action), + resource.name, + getDocsUrlForResourceList(resource)); } private List generateNewEvents(Spec oldVersion, Spec newerVersion) { List> oldEvents = oldVersion.extractWebhookInfo(false); List> newEvents = newerVersion.extractWebhookInfo(false); - Set existingEventTypes = oldEvents.stream() - .map(event -> event.get("type")) - .collect(Collectors.toSet()); + Set existingEventTypes = + oldEvents.stream().map(event -> event.get("type")).collect(Collectors.toSet()); return newEvents.stream() - .filter(event -> !existingEventTypes.contains(event.get("type"))) - .map(this::formatNewEventLine) - .distinct() - .collect(Collectors.toList()); + .filter(event -> !existingEventTypes.contains(event.get("type"))) + .map(this::formatNewEventLine) + .distinct() + .collect(Collectors.toList()); } private String formatNewEventLine(Map event) { @@ -242,18 +311,21 @@ private String formatNewEventLine(Map event) { return String.format("- [`%s`](%s) has been added.", eventType, getDocsUrlForEvent(eventType)); } - private List generateDeletedResources(List oldResources, List newResources) { + private List generateDeletedResources( + List oldResources, List newResources) { Set currentResourceIds = extractResourceIds(newResources); return oldResources.stream() - .filter(resource -> !currentResourceIds.contains(resource.id)) - .map(resource -> String.format("- %s has been removed.", resource.name)) - .distinct() - .collect(Collectors.toList()); + .filter(resource -> !currentResourceIds.contains(resource.id)) + .map(resource -> String.format("- %s has been removed.", resource.name)) + .distinct() + .collect(Collectors.toList()); } - private List generateDeletedActions(List oldResources, List newResources) { - Map newResourceMap = newResources.stream() + private List generateDeletedActions( + List oldResources, List newResources) { + Map newResourceMap = + newResources.stream() .collect(Collectors.toMap(resource -> resource.id, resource -> resource)); Set lines = new LinkedHashSet<>(); @@ -270,10 +342,10 @@ private List generateDeletedActions(List oldResources, List(lines); } - private void addDeletedActionsForExistingResource(Resource oldResource, Resource newResource, Set lines) { - Set currentActions = newResource.actions.stream() - .map(action -> action.name) - .collect(Collectors.toSet()); + private void addDeletedActionsForExistingResource( + Resource oldResource, Resource newResource, Set lines) { + Set currentActions = + newResource.actions.stream().map(action -> action.name).collect(Collectors.toSet()); for (Action oldAction : oldResource.actions) { if (!currentActions.contains(oldAction.name)) { @@ -290,15 +362,18 @@ private void addDeletedActionsForRemovedResource(Resource oldResource, Set generateDeletedAttributes(List oldResources, List newResources) { - Map newResourceMap = newResources.stream() + private List generateDeletedAttributes( + List oldResources, List newResources) { + Map newResourceMap = + newResources.stream() .collect(Collectors.toMap(resource -> resource.id, resource -> resource)); Set lines = new LinkedHashSet<>(); @@ -315,8 +390,10 @@ private List generateDeletedAttributes(List oldResources, List return new ArrayList<>(lines); } - private void addDeletedAttributesForExistingResource(Resource oldResource, Resource newResource, Set lines) { - Set currentAttributes = newResource.attributes().stream() + private void addDeletedAttributesForExistingResource( + Resource oldResource, Resource newResource, Set lines) { + Set currentAttributes = + newResource.attributes().stream() .map(attribute -> attribute.name) .collect(Collectors.toSet()); @@ -333,29 +410,37 @@ private void addDeletedAttributesForRemovedResource(Resource oldResource, Set generateDeletedParameters(List oldResources, List newResources) { - List deletedQueryParameters = generateDeletedParameterLines( + private List generateDeletedParameters( + List oldResources, List newResources) { + List deletedQueryParameters = + generateDeletedParameterLines( oldResources, newResources, Action::queryParameters, QUERY_PARAMETER_TYPE); - List deletedBodyParameters = generateDeletedParameterLines( + List deletedBodyParameters = + generateDeletedParameterLines( oldResources, newResources, Action::requestBodyParameters, REQUEST_BODY_PARAMETER_TYPE); return Stream.concat(deletedQueryParameters.stream(), deletedBodyParameters.stream()) - .collect(Collectors.toList()); + .collect(Collectors.toList()); } - private List generateDeletedParameterLines(List oldResources, List newResources, - Function> parameterExtractor, - String parameterType) { - Map newResourceMap = newResources.stream() + private List generateDeletedParameterLines( + List oldResources, + List newResources, + Function> parameterExtractor, + String parameterType) { + Map newResourceMap = + newResources.stream() .collect(Collectors.toMap(resource -> resource.id, resource -> resource)); Set lines = new LinkedHashSet<>(); @@ -363,95 +448,168 @@ private List generateDeletedParameterLines(List oldResources, Resource correspondingNewResource = newResourceMap.get(oldResource.id); if (correspondingNewResource != null) { - processDeletedParametersForExistingResource(oldResource, correspondingNewResource, - parameterExtractor, parameterType, lines); + processDeletedParametersForExistingResource( + oldResource, correspondingNewResource, parameterExtractor, parameterType, lines); } else { - processDeletedParametersForRemovedResource(oldResource, parameterExtractor, parameterType, lines); + processDeletedParametersForRemovedResource( + oldResource, parameterExtractor, parameterType, lines); } } return new ArrayList<>(lines); } - private void processDeletedParametersForExistingResource(Resource oldResource, Resource newResource, - Function> parameterExtractor, - String parameterType, Set lines) { - Map newActionMap = newResource.actions.stream() + private void processDeletedParametersForExistingResource( + Resource oldResource, + Resource newResource, + Function> parameterExtractor, + String parameterType, + Set lines) { + Map newActionMap = + newResource.actions.stream() .collect(Collectors.toMap(action -> action.id, action -> action)); for (Action oldAction : oldResource.actions) { Action correspondingNewAction = newActionMap.get(oldAction.id); if (correspondingNewAction != null) { - findAndAddDeletedParameters(oldResource, oldAction, newResource, correspondingNewAction, - parameterExtractor, parameterType, lines, true, true); + findAndAddDeletedParameters( + oldResource, + oldAction, + newResource, + correspondingNewAction, + parameterExtractor, + parameterType, + lines, + true, + true); } else { - findAndAddDeletedParameters(oldResource, oldAction, newResource, oldAction, - parameterExtractor, parameterType, lines, true, false); + findAndAddDeletedParameters( + oldResource, + oldAction, + newResource, + oldAction, + parameterExtractor, + parameterType, + lines, + true, + false); } } } - private void processDeletedParametersForRemovedResource(Resource oldResource, - Function> parameterExtractor, - String parameterType, Set lines) { + private void processDeletedParametersForRemovedResource( + Resource oldResource, + Function> parameterExtractor, + String parameterType, + Set lines) { for (Action action : oldResource.actions) { for (Parameter parameter : parameterExtractor.apply(action)) { - lines.add(formatDeletedParameter(oldResource, action, parameter, parameterType, false, false)); + lines.add( + formatDeletedParameter(oldResource, action, parameter, parameterType, false, false)); } } } - private void findAndAddDeletedParameters(Resource oldResource, Action oldAction, Resource newResource, - Action newAction, Function> parameterExtractor, - String parameterType, Set lines, - boolean includeResourceHyperlink, boolean includeActionHyperlink) { + private void findAndAddDeletedParameters( + Resource oldResource, + Action oldAction, + Resource newResource, + Action newAction, + Function> parameterExtractor, + String parameterType, + Set lines, + boolean includeResourceHyperlink, + boolean includeActionHyperlink) { Set currentParameters = collectParameterNames(parameterExtractor.apply(newAction)); for (Parameter oldParameter : parameterExtractor.apply(oldAction)) { if (!currentParameters.contains(oldParameter.getName())) { - lines.add(formatDeletedParameter(newResource, newAction, oldParameter, parameterType, - includeResourceHyperlink, includeActionHyperlink)); + lines.add( + formatDeletedParameter( + newResource, + newAction, + oldParameter, + parameterType, + includeResourceHyperlink, + includeActionHyperlink)); } else if (oldParameter.schema.getProperties() != null) { - processDeletedNestedParameters(oldParameter.schema, oldParameter.getName(), currentParameters, - newResource, newAction, parameterType, lines); + processDeletedNestedParameters( + oldParameter.schema, + oldParameter.getName(), + currentParameters, + newResource, + newAction, + parameterType, + lines); } } } - private void processDeletedNestedParameters(Schema schema, String path, Set currentParameters, - Resource resource, Action action, String parameterType, - Set lines) { + private void processDeletedNestedParameters( + Schema schema, + String path, + Set currentParameters, + Resource resource, + Action action, + String parameterType, + Set lines) { if (schema.getProperties() == null) { return; } - schema.getProperties().forEach((key, value) -> { - String nestedPath = path + "." + key; - - if (!currentParameters.contains(nestedPath)) { - Parameter nestedParameter = new Parameter(nestedPath, (Schema) value); - lines.add(formatDeletedParameter(resource, action, nestedParameter, parameterType, true, true)); - } else { - processDeletedNestedParameters((Schema) value, nestedPath, currentParameters, - resource, action, parameterType, lines); - } - }); - } - - private String formatDeletedParameter(Resource resource, Action action, Parameter parameter, - String parameterType, boolean includeResourceHyperlink, - boolean includeActionHyperlink) { + schema + .getProperties() + .forEach( + (key, value) -> { + String nestedPath = path + "." + key; + + if (!currentParameters.contains(nestedPath)) { + Parameter nestedParameter = new Parameter(nestedPath, (Schema) value); + lines.add( + formatDeletedParameter( + resource, action, nestedParameter, parameterType, true, true)); + } else { + processDeletedNestedParameters( + (Schema) value, + nestedPath, + currentParameters, + resource, + action, + parameterType, + lines); + } + }); + } + + private String formatDeletedParameter( + Resource resource, + Action action, + Parameter parameter, + String parameterType, + boolean includeResourceHyperlink, + boolean includeActionHyperlink) { if (includeResourceHyperlink && includeActionHyperlink) { - return String.format("- `%s` has been removed as %s from [`%s`](%s) in [`%s`](%s).", - parameter.getName(), parameterType, action.id, getDocsUrlForActions(resource, action), - resource.name, getDocsUrlForResourceList(resource)); + return String.format( + "- `%s` has been removed as %s from [`%s`](%s) in [`%s`](%s).", + parameter.getName(), + parameterType, + action.id, + getDocsUrlForActions(resource, action), + resource.name, + getDocsUrlForResourceList(resource)); } else if (includeResourceHyperlink) { - return String.format("- `%s` has been removed as %s from `%s` in [`%s`](%s).", - parameter.getName(), parameterType, action.id, resource.name, getDocsUrlForResourceList(resource)); + return String.format( + "- `%s` has been removed as %s from `%s` in [`%s`](%s).", + parameter.getName(), + parameterType, + action.id, + resource.name, + getDocsUrlForResourceList(resource)); } else { - return String.format("- `%s` has been removed as %s from `%s` in `%s`.", - parameter.getName(), parameterType, action.id, resource.name); + return String.format( + "- `%s` has been removed as %s from `%s` in `%s`.", + parameter.getName(), parameterType, action.id, resource.name); } } @@ -459,19 +617,18 @@ private List generateDeletedEvents(Spec oldVersion, Spec newerVersion) { List> oldEvents = oldVersion.extractWebhookInfo(false); List> newEvents = newerVersion.extractWebhookInfo(false); - Set currentEventTypes = newEvents.stream() - .map(event -> event.get("type")) - .collect(Collectors.toSet()); + Set currentEventTypes = + newEvents.stream().map(event -> event.get("type")).collect(Collectors.toSet()); return oldEvents.stream() - .filter(event -> !currentEventTypes.contains(event.get("type"))) - .map(event -> String.format("- `%s` has been removed.", event.get("type"))) - .distinct() - .collect(Collectors.toList()); + .filter(event -> !currentEventTypes.contains(event.get("type"))) + .map(event -> String.format("- `%s` has been removed.", event.get("type"))) + .distinct() + .collect(Collectors.toList()); } - private List generateNewEnumValues(List oldResources, List newResources, - Spec oldSpec, Spec newSpec) { + private List generateNewEnumValues( + List oldResources, List newResources, Spec oldSpec, Spec newSpec) { Set lines = new LinkedHashSet<>(); lines.addAll(generateGlobalEnumLines(oldSpec.globalEnums(), newSpec.globalEnums(), true)); @@ -481,8 +638,8 @@ private List generateNewEnumValues(List oldResources, List(lines); } - private List generateDeletedEnumValues(List oldResources, List newResources, - Spec oldSpec, Spec newSpec) { + private List generateDeletedEnumValues( + List oldResources, List newResources, Spec oldSpec, Spec newSpec) { Set lines = new LinkedHashSet<>(); lines.addAll(generateGlobalEnumLines(oldSpec.globalEnums(), newSpec.globalEnums(), false)); @@ -492,18 +649,23 @@ private List generateDeletedEnumValues(List oldResources, List return new ArrayList<>(lines); } - private List generateGlobalEnumLines(List oldEnums, List newEnums, boolean isAdded) { + private List generateGlobalEnumLines( + List oldEnums, List newEnums, boolean isAdded) { List lines = new ArrayList<>(); - Map> enumValuesMap = oldEnums.stream() - .collect(Collectors.toMap(e -> e.name, e -> new HashSet<>(e.values()))); + Map> enumValuesMap = + oldEnums.stream().collect(Collectors.toMap(e -> e.name, e -> new HashSet<>(e.values()))); for (Enum currentEnum : newEnums) { - Set comparisonValues = enumValuesMap.getOrDefault(currentEnum.name, Collections.emptySet()); - List changedValues = findChangedEnumValues(currentEnum.values(), comparisonValues, isAdded); + Set comparisonValues = + enumValuesMap.getOrDefault(currentEnum.name, Collections.emptySet()); + List changedValues = + findChangedEnumValues(currentEnum.values(), comparisonValues, isAdded); if (!changedValues.isEmpty()) { String prefix = isAdded ? "" : "from global "; - lines.add(String.format("- %s %senum `%s`.", + lines.add( + String.format( + "- %s %senum `%s`.", formatValuesList(changedValues, isAdded), prefix, currentEnum.name)); } } @@ -511,8 +673,8 @@ private List generateGlobalEnumLines(List oldEnums, List new return lines; } - private List generateAttributeEnumLines(List oldResources, List newResources, - boolean isAdded) { + private List generateAttributeEnumLines( + List oldResources, List newResources, boolean isAdded) { Map>> oldEnumMap = buildAttributeEnumMap(oldResources); Map>> newEnumMap = buildAttributeEnumMap(newResources); @@ -521,193 +683,305 @@ private List generateAttributeEnumLines(List oldResources, Lis Map>> comparisonMap = isAdded ? oldEnumMap : newEnumMap; for (Resource resource : sourceResources) { - Map> comparisonEnums = comparisonMap.getOrDefault(resource.id, Collections.emptyMap()); - processAttributeEnumChanges(resource, resource.attributes(), "", comparisonEnums, lines, isAdded); + Map> comparisonEnums = + comparisonMap.getOrDefault(resource.id, Collections.emptyMap()); + processAttributeEnumChanges( + resource, resource.attributes(), "", comparisonEnums, lines, isAdded); } return lines; } - private void processAttributeEnumChanges(Resource resource, List attributes, String path, - Map> comparisonEnums, List lines, - boolean isAdded) { + private void processAttributeEnumChanges( + Resource resource, + List attributes, + String path, + Map> comparisonEnums, + List lines, + boolean isAdded) { for (Attribute attribute : attributes) { String currentPath = buildAttributePath(path, attribute.name); String anchorId = buildAttributeAnchor(path, attribute.name); if (attribute.isEnumAttribute() && !attribute.isGlobalEnumAttribute()) { - processEnumAttributeChange(resource, attribute, currentPath, anchorId, comparisonEnums, lines, isAdded); + processEnumAttributeChange( + resource, attribute, currentPath, anchorId, comparisonEnums, lines, isAdded); } if (attribute.getSubAttributes() != null) { - processAttributeEnumChanges(resource, attribute.getSubAttributes(), currentPath, - comparisonEnums, lines, isAdded); + processAttributeEnumChanges( + resource, attribute.getSubAttributes(), currentPath, comparisonEnums, lines, isAdded); } } } - private void processEnumAttributeChange(Resource resource, Attribute attribute, String path, String anchorId, - Map> comparisonEnums, List lines, - boolean isAdded) { + private void processEnumAttributeChange( + Resource resource, + Attribute attribute, + String path, + String anchorId, + Map> comparisonEnums, + List lines, + boolean isAdded) { Set comparisonValues = comparisonEnums.getOrDefault(path, Collections.emptySet()); - List changedValues = findChangedEnumValues(attribute.getEnum().values(), comparisonValues, isAdded); + List changedValues = + findChangedEnumValues(attribute.getEnum().values(), comparisonValues, isAdded); if (!changedValues.isEmpty()) { String actionVerb = isAdded ? "to" : "from"; - lines.add(String.format("- %s %s enum attribute [`%s`](%s#%s) in [`%s`](%s).", - formatValuesList(changedValues, isAdded), actionVerb, path, - getDocsUrlForResourceObject(resource), anchorId, resource.name, + lines.add( + String.format( + "- %s %s enum attribute [`%s`](%s#%s) in [`%s`](%s).", + formatValuesList(changedValues, isAdded), + actionVerb, + path, + getDocsUrlForResourceObject(resource), + anchorId, + resource.name, getDocsUrlForResourceList(resource))); } } - private List generateParameterEnumLines(List oldResources, List newResources, - boolean isAdded) { + private List generateParameterEnumLines( + List oldResources, List newResources, boolean isAdded) { List lines = new ArrayList<>(); - Map>>> oldEnumMap = collectParameterEnums(oldResources); - Map>>> newEnumMap = collectParameterEnums(newResources); + Map>>> oldEnumMap = + collectParameterEnums(oldResources); + Map>>> newEnumMap = + collectParameterEnums(newResources); List sourceResources = isAdded ? newResources : oldResources; - Map>>> comparisonMap = isAdded ? oldEnumMap : newEnumMap; + Map>>> comparisonMap = + isAdded ? oldEnumMap : newEnumMap; for (Resource resource : sourceResources) { - Map>> comparisonActions = comparisonMap.getOrDefault( - resource.id, Collections.emptyMap()); + Map>> comparisonActions = + comparisonMap.getOrDefault(resource.id, Collections.emptyMap()); for (Action action : resource.actions) { - Map> comparisonParameters = comparisonActions.getOrDefault( - action.id, Collections.emptyMap()); - - processParameterEnumChanges(resource, action, action.queryParameters(), QUERY_PREFIX, - comparisonParameters, lines, isAdded); - processParameterEnumChanges(resource, action, action.requestBodyParameters(), BODY_PREFIX, - comparisonParameters, lines, isAdded); + Map> comparisonParameters = + comparisonActions.getOrDefault(action.id, Collections.emptyMap()); + + processParameterEnumChanges( + resource, + action, + action.queryParameters(), + QUERY_PREFIX, + comparisonParameters, + lines, + isAdded); + processParameterEnumChanges( + resource, + action, + action.requestBodyParameters(), + BODY_PREFIX, + comparisonParameters, + lines, + isAdded); } } return lines; } - private void processParameterEnumChanges(Resource resource, Action action, List parameters, - String prefix, Map> comparisonParameters, - List lines, boolean isAdded) { + private void processParameterEnumChanges( + Resource resource, + Action action, + List parameters, + String prefix, + Map> comparisonParameters, + List lines, + boolean isAdded) { for (Parameter parameter : parameters) { if (shouldProcessParameterEnum(parameter)) { - processDirectParameterEnum(resource, action, parameter, prefix, comparisonParameters, lines, isAdded); + processDirectParameterEnum( + resource, action, parameter, prefix, comparisonParameters, lines, isAdded); } if (parameter.schema.getProperties() != null) { - processNestedParameterEnums(resource, action, parameter, prefix, comparisonParameters, lines, isAdded); + processNestedParameterEnums( + resource, action, parameter, prefix, comparisonParameters, lines, isAdded); } } } - private void processDirectParameterEnum(Resource resource, Action action, Parameter parameter, String prefix, - Map> comparisonParameters, List lines, - boolean isAdded) { + private void processDirectParameterEnum( + Resource resource, + Action action, + Parameter parameter, + String prefix, + Map> comparisonParameters, + List lines, + boolean isAdded) { String parameterKey = prefix + ":" + parameter.getName(); - Set comparisonValues = comparisonParameters.getOrDefault(parameterKey, Collections.emptySet()); - List changedValues = findChangedEnumValues(parameter.getEnumValues(), comparisonValues, isAdded); + Set comparisonValues = + comparisonParameters.getOrDefault(parameterKey, Collections.emptySet()); + List changedValues = + findChangedEnumValues(parameter.getEnumValues(), comparisonValues, isAdded); if (!changedValues.isEmpty()) { - String parameterType = prefix.equals(QUERY_PREFIX) ? QUERY_PARAMETER_TYPE : REQUEST_BODY_PARAMETER_TYPE; - addParameterEnumLine(lines, changedValues, resource, action, parameter, parameterType, isAdded); + String parameterType = + prefix.equals(QUERY_PREFIX) ? QUERY_PARAMETER_TYPE : REQUEST_BODY_PARAMETER_TYPE; + addParameterEnumLine( + lines, changedValues, resource, action, parameter, parameterType, isAdded); } } - private void processNestedParameterEnums(Resource resource, Action action, Parameter parameter, String prefix, - Map> comparisonParameters, List lines, - boolean isAdded) { - parameter.schema.getProperties().forEach((key, schema) -> { - Schema propertySchema = (Schema) schema; - - if (shouldProcessSchemaEnum(propertySchema)) { - String nestedParameterName = parameter.getName() + "." + key; - String parameterKey = prefix + ":" + nestedParameterName; - Set comparisonValues = comparisonParameters.getOrDefault(parameterKey, Collections.emptySet()); - List changedValues = findChangedEnumValues(new ArrayList<>(getEnumValues(propertySchema)), - comparisonValues, isAdded); - - if (!changedValues.isEmpty()) { - String parameterType = prefix.equals(QUERY_PREFIX) ? QUERY_PARAMETER_TYPE : REQUEST_BODY_PARAMETER_TYPE; - Parameter nestedParameter = new Parameter(nestedParameterName, propertySchema); - addParameterEnumLine(lines, changedValues, resource, action, nestedParameter, parameterType, isAdded); - } - } - }); - } - - private void addParameterEnumLine(List lines, List values, Resource resource, Action action, - Parameter parameter, String parameterType, boolean isAdded) { + private void processNestedParameterEnums( + Resource resource, + Action action, + Parameter parameter, + String prefix, + Map> comparisonParameters, + List lines, + boolean isAdded) { + parameter + .schema + .getProperties() + .forEach( + (key, schema) -> { + Schema propertySchema = (Schema) schema; + + if (shouldProcessSchemaEnum(propertySchema)) { + String nestedParameterName = parameter.getName() + "." + key; + String parameterKey = prefix + ":" + nestedParameterName; + Set comparisonValues = + comparisonParameters.getOrDefault(parameterKey, Collections.emptySet()); + List changedValues = + findChangedEnumValues( + new ArrayList<>(getEnumValues(propertySchema)), comparisonValues, isAdded); + + if (!changedValues.isEmpty()) { + String parameterType = + prefix.equals(QUERY_PREFIX) + ? QUERY_PARAMETER_TYPE + : REQUEST_BODY_PARAMETER_TYPE; + Parameter nestedParameter = new Parameter(nestedParameterName, propertySchema); + addParameterEnumLine( + lines, + changedValues, + resource, + action, + nestedParameter, + parameterType, + isAdded); + } + } + }); + } + + private void addParameterEnumLine( + List lines, + List values, + Resource resource, + Action action, + Parameter parameter, + String parameterType, + boolean isAdded) { String actionVerb = isAdded ? "to" : "from"; - lines.add(String.format("- %s %s enum %s `%s` in [`%s`](%s) of [`%s`](%s).", - formatValuesList(values, isAdded), actionVerb, parameterType, parameter.getName(), - action.id, getDocsUrlForActions(resource, action), resource.name, getDocsUrlForResourceList(resource))); - } - - private List generateParameterRequirementChanges(List oldResources, List newResources) { + lines.add( + String.format( + "- %s %s enum %s `%s` in [`%s`](%s) of [`%s`](%s).", + formatValuesList(values, isAdded), + actionVerb, + parameterType, + parameter.getName(), + action.id, + getDocsUrlForActions(resource, action), + resource.name, + getDocsUrlForResourceList(resource))); + } + + private List generateParameterRequirementChanges( + List oldResources, List newResources) { List lines = new ArrayList<>(); - Map>> oldRequirementMap = buildParameterRequirementMap(oldResources); + Map>> oldRequirementMap = + buildParameterRequirementMap(oldResources); for (Resource newResource : newResources) { - Map> oldActionRequirements = oldRequirementMap.getOrDefault( - newResource.id, Collections.emptyMap()); + Map> oldActionRequirements = + oldRequirementMap.getOrDefault(newResource.id, Collections.emptyMap()); for (Action newAction : newResource.actions) { - Map oldParameterRequirements = oldActionRequirements.getOrDefault( - newAction.id, Collections.emptyMap()); - - checkRequirementChanges(newResource, newAction, newAction.queryParameters(), - QUERY_PREFIX, oldParameterRequirements, lines); - checkRequirementChanges(newResource, newAction, newAction.requestBodyParameters(), - BODY_PREFIX, oldParameterRequirements, lines); + Map oldParameterRequirements = + oldActionRequirements.getOrDefault(newAction.id, Collections.emptyMap()); + + checkRequirementChanges( + newResource, + newAction, + newAction.queryParameters(), + QUERY_PREFIX, + oldParameterRequirements, + lines); + checkRequirementChanges( + newResource, + newAction, + newAction.requestBodyParameters(), + BODY_PREFIX, + oldParameterRequirements, + lines); } } return lines; } - private void checkRequirementChanges(Resource resource, Action action, List parameters, - String prefix, Map oldRequirements, List lines) { + private void checkRequirementChanges( + Resource resource, + Action action, + List parameters, + String prefix, + Map oldRequirements, + List lines) { for (Parameter parameter : parameters) { String parameterKey = prefix + ":" + parameter.getName(); - if (oldRequirements.containsKey(parameterKey) && - oldRequirements.get(parameterKey) != parameter.isRequired) { + if (oldRequirements.containsKey(parameterKey) + && oldRequirements.get(parameterKey) != parameter.isRequired) { lines.add(formatRequirementChangeLine(resource, action, parameter)); } } } - private String formatRequirementChangeLine(Resource resource, Action action, Parameter parameter) { - String changeDescription = parameter.isRequired ? "optional to required" : "required to optional"; - return String.format("- [`%s`](%s) has been changed from %s in [`%s`](%s) of [`%s`](%s).", - parameter.getName(), getDocsUrlForParameter(resource, action, parameter), changeDescription, - action.id, getDocsUrlForActions(resource, action), resource.name, getDocsUrlForResourceList(resource)); + private String formatRequirementChangeLine( + Resource resource, Action action, Parameter parameter) { + String changeDescription = + parameter.isRequired ? "optional to required" : "required to optional"; + return String.format( + "- [`%s`](%s) has been changed from %s in [`%s`](%s) of [`%s`](%s).", + parameter.getName(), + getDocsUrlForParameter(resource, action, parameter), + changeDescription, + action.id, + getDocsUrlForActions(resource, action), + resource.name, + getDocsUrlForResourceList(resource)); } private Map> buildActionMap(List resources) { return resources.stream() - .collect(Collectors.toMap( - resource -> resource.id, - resource -> resource.actions.stream() - .map(action -> action.name) - .collect(Collectors.toSet()) - )); + .collect( + Collectors.toMap( + resource -> resource.id, + resource -> + resource.actions.stream() + .map(action -> action.name) + .collect(Collectors.toSet()))); } private Map> buildAttributeMap(List resources) { return resources.stream() - .collect(Collectors.toMap( - resource -> resource.id, - resource -> resource.attributes().stream() - .map(attribute -> attribute.name) - .collect(Collectors.toSet()) - )); + .collect( + Collectors.toMap( + resource -> resource.id, + resource -> + resource.attributes().stream() + .map(attribute -> attribute.name) + .collect(Collectors.toSet()))); } - private Map>> buildParameterMap(List resources, - Function> parameterExtractor) { + private Map>> buildParameterMap( + List resources, Function> parameterExtractor) { Map>> resourceMap = new HashMap<>(); for (Resource resource : resources) { @@ -740,11 +1014,14 @@ private void collectNestedParameterNames(Schema schema, String path, Set return; } - schema.getProperties().forEach((key, value) -> { - String nestedPath = path + "." + key; - names.add(nestedPath); - collectNestedParameterNames((Schema) value, nestedPath, names); - }); + schema + .getProperties() + .forEach( + (key, value) -> { + String nestedPath = path + "." + key; + names.add(nestedPath); + collectNestedParameterNames((Schema) value, nestedPath, names); + }); } private Map>> buildAttributeEnumMap(List resources) { @@ -759,8 +1036,8 @@ private Map>> buildAttributeEnumMap(List attributes, String path, - Map> enumMap) { + private void collectAttributeEnumsRecursive( + List attributes, String path, Map> enumMap) { for (Attribute attribute : attributes) { String currentPath = buildAttributePath(path, attribute.name); @@ -774,7 +1051,8 @@ private void collectAttributeEnumsRecursive(List attributes, String p } } - private Map>>> collectParameterEnums(List resources) { + private Map>>> collectParameterEnums( + List resources) { Map>>> resourceMap = new HashMap<>(); for (Resource resource : resources) { @@ -793,8 +1071,8 @@ private Map>>> collectParameterEnums return resourceMap; } - private void collectEnumsFromParameters(List parameters, String prefix, - Map> enumMap) { + private void collectEnumsFromParameters( + List parameters, String prefix, Map> enumMap) { for (Parameter parameter : parameters) { if (shouldProcessParameterEnum(parameter)) { String key = prefix + ":" + parameter.getName(); @@ -807,19 +1085,24 @@ private void collectEnumsFromParameters(List parameters, String prefi } } - private void collectEnumsFromNestedParameters(Parameter parameter, String prefix, - Map> enumMap) { - parameter.schema.getProperties().forEach((key, schema) -> { - Schema propertySchema = (Schema) schema; + private void collectEnumsFromNestedParameters( + Parameter parameter, String prefix, Map> enumMap) { + parameter + .schema + .getProperties() + .forEach( + (key, schema) -> { + Schema propertySchema = (Schema) schema; - if (shouldProcessSchemaEnum(propertySchema)) { - String enumKey = prefix + ":" + parameter.getName() + "." + key; - enumMap.put(enumKey, getEnumValues(propertySchema)); - } - }); + if (shouldProcessSchemaEnum(propertySchema)) { + String enumKey = prefix + ":" + parameter.getName() + "." + key; + enumMap.put(enumKey, getEnumValues(propertySchema)); + } + }); } - private Map>> buildParameterRequirementMap(List resources) { + private Map>> buildParameterRequirementMap( + List resources) { Map>> resourceMap = new HashMap<>(); for (Resource resource : resources) { @@ -838,32 +1121,30 @@ private Map>> buildParameterRequirement return resourceMap; } - private void collectParameterRequirements(List parameters, String prefix, - Map requirementMap) { + private void collectParameterRequirements( + List parameters, String prefix, Map requirementMap) { for (Parameter parameter : parameters) { String key = prefix + ":" + parameter.getName(); requirementMap.put(key, parameter.isRequired); } } - private List findChangedEnumValues(List currentValues, Set comparisonValues, - boolean isAdded) { + private List findChangedEnumValues( + List currentValues, Set comparisonValues, boolean isAdded) { if (isAdded) { return currentValues.stream() - .filter(value -> !comparisonValues.contains(value)) - .collect(Collectors.toList()); + .filter(value -> !comparisonValues.contains(value)) + .collect(Collectors.toList()); } else { return currentValues.stream() - .filter(comparisonValues::contains) - .filter(value -> !currentValues.contains(value)) - .collect(Collectors.toList()); + .filter(comparisonValues::contains) + .filter(value -> !currentValues.contains(value)) + .collect(Collectors.toList()); } } private Set extractResourceIds(List resources) { - return resources.stream() - .map(resource -> resource.id) - .collect(Collectors.toSet()); + return resources.stream().map(resource -> resource.id).collect(Collectors.toSet()); } private String buildAttributePath(String basePath, String attributeName) { @@ -887,10 +1168,12 @@ private String formatValuesList(List values, boolean isAdded) { } if (values.size() == 2) { - return String.format("`%s` and `%s` have been %s", values.get(0), values.get(1), pluralAction); + return String.format( + "`%s` and `%s` have been %s", values.get(0), values.get(1), pluralAction); } - String allButLast = values.subList(0, values.size() - 1).stream() + String allButLast = + values.subList(0, values.size() - 1).stream() .map(value -> "`" + value + "`") .collect(Collectors.joining(", ")); String lastValue = values.get(values.size() - 1); @@ -899,17 +1182,17 @@ private String formatValuesList(List values, boolean isAdded) { } private boolean shouldProcessParameterEnum(Parameter parameter) { - return parameter.isEnum() && - !parameter.isGlobalEnum() && - !parameter.isExternalEnum() && - !parameter.isGenSeperate(); + return parameter.isEnum() + && !parameter.isGlobalEnum() + && !parameter.isExternalEnum() + && !parameter.isGenSeperate(); } private boolean shouldProcessSchemaEnum(Schema schema) { - return isEnumSchema(schema) && - !isGlobalEnumSchema(schema) && - !isExternalEnumSchema(schema) && - !isGenSeparateSchema(schema); + return isEnumSchema(schema) + && !isGlobalEnumSchema(schema) + && !isExternalEnumSchema(schema) + && !isGenSeparateSchema(schema); } private boolean isEnumSchema(Schema schema) { @@ -935,14 +1218,16 @@ private boolean isGenSeparateSchema(Schema schema) { } private boolean hasExtension(Schema schema, String extensionKey) { - return schema.getExtensions() != null && - schema.getExtensions().get(extensionKey) != null && - (boolean) schema.getExtensions().get(extensionKey); + return schema.getExtensions() != null + && schema.getExtensions().get(extensionKey) != null + && (boolean) schema.getExtensions().get(extensionKey); } private Set getEnumValues(Schema schema) { - if (isGlobalEnumSchema(schema) || isExternalEnumSchema(schema) || - !isEnumSchema(schema) || isGenSeparateSchema(schema)) { + if (isGlobalEnumSchema(schema) + || isExternalEnumSchema(schema) + || !isEnumSchema(schema) + || isGenSeparateSchema(schema)) { return Collections.emptySet(); } return new HashSet<>(new Enum(schema).values()); @@ -962,13 +1247,15 @@ private String getDocsUrlForResourceList(Resource resource) { private String getDocsUrlForResourceObject(Resource resource) { String hyphenatedResourceId = resource.id.replace("_", "-"); - return String.format("https://apidocs.chargebee.com/docs/api/%s/%s-object", - pluralize(resource.id), hyphenatedResourceId); + return String.format( + "https://apidocs.chargebee.com/docs/api/%s/%s-object", + pluralize(resource.id), hyphenatedResourceId); } private String getDocsUrlForActions(Resource resource, Action action) { - return String.format("https://apidocs.chargebee.com/docs/api/%s/%s", - pluralize(resource.id), toHyphenCase(action.id)); + return String.format( + "https://apidocs.chargebee.com/docs/api/%s/%s", + pluralize(resource.id), toHyphenCase(action.id)); } private String getDocsUrlForAttribute(Resource resource, Attribute attribute) { @@ -977,7 +1264,8 @@ private String getDocsUrlForAttribute(Resource resource, Attribute attribute) { private String getDocsUrlForParameter(Resource resource, Action action, Parameter parameter) { String anchorId = parameter.getName().replace(".", "_"); - return String.format("https://apidocs.chargebee.com/docs/api/%s/%s#%s", - pluralize(resource.id), toHyphenCase(action.id), anchorId); + return String.format( + "https://apidocs.chargebee.com/docs/api/%s/%s#%s", + pluralize(resource.id), toHyphenCase(action.id), anchorId); } -} \ No newline at end of file +} diff --git a/src/main/java/com/chargebee/sdk/node/NodeV3.java b/src/main/java/com/chargebee/sdk/node/NodeV3.java index 9558c5e..1f5d98d 100644 --- a/src/main/java/com/chargebee/sdk/node/NodeV3.java +++ b/src/main/java/com/chargebee/sdk/node/NodeV3.java @@ -4,6 +4,7 @@ import com.chargebee.openapi.Spec; import com.chargebee.sdk.FileOp; import com.chargebee.sdk.Language; +import com.chargebee.sdk.node.webhook.WebhookGenerator; import com.github.jknack.handlebars.Template; import java.io.IOException; import java.util.*; @@ -19,16 +20,37 @@ protected List generateSDK(String outputDirectoryPath, Spec spec) throws .filter(resource -> !Arrays.stream(this.hiddenOverride).toList().contains(resource.id)) .sorted(Comparator.comparing(Resource::sortOrder)) .toList(); - List fileOps = new ArrayList<>(); fileOps.add(generateApiEndpointsFile(outputDirectoryPath, resources)); - // Generate webhook event types file - fileOps.addAll(generateWebhookEventTypes(outputDirectoryPath, spec)); + // Generate webhook files (content, handler, auth, eventType, errors) + { + Template contentTemplate = getTemplateContent("webhookContent"); + Template handlerTemplate = getTemplateContent("webhookHandler"); + Template authTemplate = getTemplateContent("webhookAuth"); + Template eventTypesTemplate = getTemplateContent("webhookEventTypes"); + Template errorsTemplate = getTemplateContent("webhookErrors"); + fileOps.addAll( + WebhookGenerator.generate( + outputDirectoryPath, + spec, + contentTemplate, + handlerTemplate, + authTemplate, + eventTypesTemplate, + errorsTemplate)); + } // Generate entry point files (in parent directory of resources) - String parentDirectoryPath = outputDirectoryPath.replace("/resources", ""); - fileOps.addAll(generateEntryPoints(parentDirectoryPath)); + { + String parentDirectoryPath = outputDirectoryPath.replace("/resources", ""); + Template esmTemplate = getTemplateContent("chargebeeEsm"); + Template cjsTemplate = getTemplateContent("chargebeeCjs"); + fileOps.add( + new FileOp.WriteString(parentDirectoryPath, "chargebee.esm.ts", esmTemplate.apply(""))); + fileOps.add( + new FileOp.WriteString(parentDirectoryPath, "chargebee.cjs.ts", cjsTemplate.apply(""))); + } return fileOps; } @@ -37,7 +59,11 @@ protected List generateSDK(String outputDirectoryPath, Spec spec) throws protected Map templatesDefinition() { var templates = new HashMap(); templates.put("api_endpoints", "/templates/node/api_endpoints.ts.hbs"); + templates.put("webhookContent", "/templates/node/webhook_content.ts.hbs"); + templates.put("webhookHandler", "/templates/node/webhook_handler.ts.hbs"); + templates.put("webhookAuth", "/templates/node/webhook_auth.ts.hbs"); templates.put("webhookEventTypes", "/templates/node/webhook_event_types.ts.hbs"); + templates.put("webhookErrors", "/templates/node/webhook_errors.ts.hbs"); templates.put("chargebeeEsm", "/templates/node/chargebee_esm.ts.hbs"); templates.put("chargebeeCjs", "/templates/node/chargebee_cjs.ts.hbs"); return templates; diff --git a/src/main/java/com/chargebee/sdk/node/webhook/WebhookGenerator.java b/src/main/java/com/chargebee/sdk/node/webhook/WebhookGenerator.java new file mode 100644 index 0000000..e5782ad --- /dev/null +++ b/src/main/java/com/chargebee/sdk/node/webhook/WebhookGenerator.java @@ -0,0 +1,168 @@ +package com.chargebee.sdk.node.webhook; + +import com.chargebee.openapi.Attribute; +import com.chargebee.openapi.Resource; +import com.chargebee.openapi.Spec; +import com.chargebee.sdk.FileOp; +import com.github.jknack.handlebars.Template; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Schema; +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +public class WebhookGenerator { + private static List getEventResourcesForAEvent(Resource eventResource, Spec spec) { + List resources = new ArrayList<>(); + for (Attribute attribute : eventResource.attributes()) { + if (attribute.name.equals("content")) { + attribute + .attributes() + .forEach( + (innerAttribute -> { + Schema schema = innerAttribute.schema; + String ref = null; + boolean isArray = false; + if (schema instanceof ArraySchema) { + ArraySchema arraySchema = (ArraySchema) schema; + Schema itemSchema = arraySchema.getItems(); + if (itemSchema != null) { + ref = itemSchema.get$ref(); + isArray = true; + } + } else { + ref = schema.get$ref(); + } + Set hiddenResourceNames = getHiddenResources(spec); + if (ref != null && ref.contains("/")) { + String schemaName = ref.substring(ref.lastIndexOf("/") + 1); + if (hiddenResourceNames.contains(schemaName)) { + return; + } + if (isArray) { + resources.add( + String.format("%s: import('chargebee').%s[];", schemaName, schemaName)); + } else { + resources.add( + String.format("%s: import('chargebee').%s;", schemaName, schemaName)); + } + } + })); + } + } + return resources; + } + + public static List generate( + String outputDirectoryPath, + Spec spec, + Template contentTemplate, + Template handlerTemplate, + Template authTemplate, + Template eventTypesTemplate, + Template errorsTemplate) + throws IOException { + final String webhookDirectoryPath = "/webhook"; + List fileOps = new ArrayList<>(); + // Ensure webhook directory exists + fileOps.add(new FileOp.CreateDirectory(outputDirectoryPath, webhookDirectoryPath)); + + // Include deprecated webhook events (like PCV1) since customers may still receive them + var webhookInfo = spec.extractWebhookInfo(true); + var eventSchema = spec.resourcesForEvents(); + + if (webhookInfo.isEmpty()) { + return fileOps; + } + + List> events = new ArrayList<>(); + Set seenTypes = new HashSet<>(); + Set uniqueImports = new HashSet<>(); + + for (Map info : webhookInfo) { + String type = info.get("type"); + if (seenTypes.contains(type)) { + continue; + } + seenTypes.add(type); + + String resourceSchemaName = info.get("resource_schema_name"); + Resource matchedSchema = + eventSchema.stream() + .filter(schema -> schema.name.equals(resourceSchemaName)) + .findFirst() + .orElse(null); + + List allSchemas = getEventResourcesForAEvent(matchedSchema, spec); + List schemaImports = new ArrayList<>(); + + for (String schema : allSchemas) { + schemaImports.add(schema); + uniqueImports.add(schema); + } + + Map params = new HashMap<>(); + params.put("type", type); + params.put("resource_schemas", schemaImports); + events.add(params); + } + + events.sort(Comparator.comparing(e -> e.get("type").toString())); + + // content.ts + { + Map ctx = new HashMap<>(); + ctx.put("events", events); + List importsList = new ArrayList<>(uniqueImports); + Collections.sort(importsList); + ctx.put("unique_imports", importsList); + + fileOps.add( + new FileOp.WriteString( + outputDirectoryPath + webhookDirectoryPath, + "content.ts", + contentTemplate.apply(ctx))); + } + + // handler.ts (static template) + { + fileOps.add( + new FileOp.WriteString( + outputDirectoryPath + webhookDirectoryPath, "handler.ts", handlerTemplate.apply(""))); + } + + // auth.ts + { + fileOps.add( + new FileOp.WriteString( + outputDirectoryPath + webhookDirectoryPath, "auth.ts", authTemplate.apply(""))); + } + + // errors.ts + { + fileOps.add( + new FileOp.WriteString( + outputDirectoryPath + webhookDirectoryPath, "errors.ts", errorsTemplate.apply(""))); + } + + // eventType.ts + { + Map ctx = new HashMap<>(); + ctx.put("events", events); + fileOps.add( + new FileOp.WriteString( + outputDirectoryPath + webhookDirectoryPath, + "eventType.ts", + eventTypesTemplate.apply(ctx))); + } + + return fileOps; + } + + public static Set getHiddenResources(Spec spec) { + return spec.allResources().stream() + .filter((res) -> !res.isNotHiddenFromSDKGeneration()) + .map((res) -> res.name) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/resources/templates/node/chargebee_cjs.ts.hbs b/src/main/resources/templates/node/chargebee_cjs.ts.hbs index 8985099..c506791 100644 --- a/src/main/resources/templates/node/chargebee_cjs.ts.hbs +++ b/src/main/resources/templates/node/chargebee_cjs.ts.hbs @@ -1,6 +1,7 @@ import { CreateChargebee } from './createChargebee.js'; import { FetchHttpClient } from './net/FetchClient.js'; -import { WebhookEventType, WebhookContentType } from './resources/webhook/eventType.js'; +import { WebhookEventType, WebhookContentType, WebhookError, WebhookAuthenticationError, WebhookPayloadValidationError, WebhookPayloadParseError } from './resources/webhook/handler.js'; +import { basicAuthValidator } from './resources/webhook/auth.js'; const httpClient = new FetchHttpClient(); const Chargebee = CreateChargebee(httpClient); @@ -8,7 +9,17 @@ module.exports = Chargebee; module.exports.Chargebee = Chargebee; module.exports.default = Chargebee; -// Export webhook event types +// Export webhook utilities module.exports.WebhookEventType = WebhookEventType; module.exports.WebhookContentType = WebhookContentType; +module.exports.basicAuthValidator = basicAuthValidator; +// Export webhook error classes +module.exports.WebhookError = WebhookError; +module.exports.WebhookAuthenticationError = WebhookAuthenticationError; +module.exports.WebhookPayloadValidationError = WebhookPayloadValidationError; +module.exports.WebhookPayloadParseError = WebhookPayloadParseError; + +// Export webhook types +export type { WebhookEvent, WebhookContext, WebhookHandlerOptions, HandleOptions, RequestValidator } from './resources/webhook/handler.js'; +export type { CredentialValidator } from './resources/webhook/auth.js'; diff --git a/src/main/resources/templates/node/chargebee_esm.ts.hbs b/src/main/resources/templates/node/chargebee_esm.ts.hbs index 6ffa8cd..c691fb2 100644 --- a/src/main/resources/templates/node/chargebee_esm.ts.hbs +++ b/src/main/resources/templates/node/chargebee_esm.ts.hbs @@ -6,6 +6,11 @@ const Chargebee = CreateChargebee(httpClient); export default Chargebee; -// Export webhook event types -export { WebhookEventType, WebhookContentType } from './resources/webhook/eventType.js'; +// Export webhook utilities +export { WebhookEventType, WebhookContentType } from './resources/webhook/handler.js'; +export { basicAuthValidator } from './resources/webhook/auth.js'; +export { WebhookError, WebhookAuthenticationError, WebhookPayloadValidationError, WebhookPayloadParseError } from './resources/webhook/handler.js'; +// Export webhook types +export type { WebhookEvent, WebhookContext, WebhookHandlerOptions, HandleOptions, RequestValidator } from './resources/webhook/handler.js'; +export type { CredentialValidator } from './resources/webhook/auth.js'; diff --git a/src/main/resources/templates/node/webhook_auth.ts.hbs b/src/main/resources/templates/node/webhook_auth.ts.hbs new file mode 100644 index 0000000..fdb472a --- /dev/null +++ b/src/main/resources/templates/node/webhook_auth.ts.hbs @@ -0,0 +1,68 @@ +import { WebhookAuthenticationError } from './errors.js'; + +/** + * Credential validator function type. + * Can be synchronous or asynchronous (e.g., for database lookups). + */ +export type CredentialValidator = ( + username: string, + password: string, +) => boolean | Promise; + +/** + * Creates a Basic Auth validator for webhook requests. + * Parses the Authorization header and validates credentials. + * + * @param validateCredentials - Function to validate username/password. + * Can be sync or async (e.g., for database lookups). + * @returns A request validator function for use with WebhookHandler + * + * @throws {WebhookAuthenticationError} When authentication fails + * + * @example + * // Simple sync validation + * const validator = basicAuthValidator((u, p) => u === 'admin' && p === 'secret'); + * + * @example + * // Async validation (e.g., database lookup) + * const validator = basicAuthValidator(async (u, p) => { + * const user = await db.findUser(u); + * return user && await bcrypt.compare(p, user.passwordHash); + * }); + */ +export const basicAuthValidator = ( + validateCredentials: CredentialValidator, +) => { + return async (headers: Record): Promise => { + const authHeader = headers['authorization'] || headers['Authorization']; + + if (!authHeader) { + throw new WebhookAuthenticationError('Missing authorization header'); + } + + const authStr = Array.isArray(authHeader) ? authHeader[0] : authHeader; + if (!authStr) { + throw new WebhookAuthenticationError('Invalid authorization header'); + } + + const parts = authStr.split(' '); + if (parts.length !== 2 || parts[0] !== 'Basic') { + throw new WebhookAuthenticationError('Invalid authorization header format'); + } + + const decoded = Buffer.from(parts[1], 'base64').toString(); + const separatorIndex = decoded.indexOf(':'); + + if (separatorIndex === -1) { + throw new WebhookAuthenticationError('Invalid credentials format'); + } + + const username = decoded.substring(0, separatorIndex); + const password = decoded.substring(separatorIndex + 1); + + const isValid = await validateCredentials(username, password); + if (!isValid) { + throw new WebhookAuthenticationError('Invalid credentials'); + } + }; +}; diff --git a/src/main/resources/templates/node/webhook_content.ts.hbs b/src/main/resources/templates/node/webhook_content.ts.hbs new file mode 100644 index 0000000..2a8327a --- /dev/null +++ b/src/main/resources/templates/node/webhook_content.ts.hbs @@ -0,0 +1,55 @@ +/// +{{#each events}} + +export interface {{snakeCaseToPascalCase type}}Content { +{{#each resource_schemas}} + {{{.}}} +{{/each}} +} +{{/each}} + +import { WebhookEventType } from './eventType.js'; + +/** + * Maps webhook event types to their corresponding content types. + * Used for type-safe access to event.content based on event_type. + */ +export type WebhookContentMap = { +{{#each events}} + [WebhookEventType.{{snakeCaseToPascalCase type}}]: {{snakeCaseToPascalCase type}}Content; +{{/each}} +}; + +/** + * Utility type to get the content type for a specific webhook event type. + * @example + * type SubCreatedContent = ContentFor; + */ +export type ContentFor = WebhookContentMap[T]; + +/** + * Webhook event payload from Chargebee. + * + * @typeParam T - The specific event type. When provided, `content` is strongly typed. + * Defaults to `WebhookEventType` for backward compatibility (content becomes union of all types). + * + * @example + * // Backward compatible usage (content is union of all content types) + * const event: WebhookEvent = payload; + * + * // Type-safe usage with specific event type + * const event: WebhookEvent = payload; + * event.content.Subscription; // ✓ Typed as Subscription + */ +export interface WebhookEvent { + id: string; + occurred_at: number; + source: string; + user?: string; + webhook_status: string; + webhook_failure_reason?: string; + webhooks?: any[]; + event_type: T; + api_version: string; + content: ContentFor; +} diff --git a/src/main/resources/templates/node/webhook_errors.ts.hbs b/src/main/resources/templates/node/webhook_errors.ts.hbs new file mode 100644 index 0000000..e858265 --- /dev/null +++ b/src/main/resources/templates/node/webhook_errors.ts.hbs @@ -0,0 +1,92 @@ +/** + * Base class for all webhook-related errors. + * Extends the standard Error class with proper stack trace support. + */ +export class WebhookError extends Error { + constructor(message: string) { + super(message); + this.name = 'WebhookError'; + // Maintains proper stack trace for where error was thrown (V8 only) + Error.captureStackTrace?.(this, this.constructor); + } +} + +/** + * Authentication error thrown when webhook request authentication fails. + * + * Common scenarios: + * - Missing authorization header + * - Invalid authorization header format + * - Invalid credentials + * + * Typically maps to HTTP 401 Unauthorized. + * + * @example + * ```typescript + * handler.on('error', (error, { response }) => { + * if (error instanceof WebhookAuthenticationError) { + * response?.status(401).json({ error: 'Unauthorized' }); + * } + * }); + * ``` + */ +export class WebhookAuthenticationError extends WebhookError { + constructor(message: string) { + super(message); + this.name = 'WebhookAuthenticationError'; + } +} + +/** + * Payload validation error thrown when the webhook payload structure is invalid. + * + * Common scenarios: + * - Missing required fields (event_type, id) + * - Invalid field types + * - Malformed payload structure + * + * Typically maps to HTTP 400 Bad Request. + * + * @example + * ```typescript + * handler.on('error', (error, { response }) => { + * if (error instanceof WebhookPayloadValidationError) { + * response?.status(400).json({ error: 'Bad Request', message: error.message }); + * } + * }); + * ``` + */ +export class WebhookPayloadValidationError extends WebhookError { + constructor(message: string) { + super(message); + this.name = 'WebhookPayloadValidationError'; + } +} + +/** + * JSON parsing error thrown when the webhook body cannot be parsed as JSON. + * + * Includes the raw body that failed to parse (if available) for debugging. + * + * Typically maps to HTTP 400 Bad Request. + * + * @example + * ```typescript + * handler.on('error', (error, { response }) => { + * if (error instanceof WebhookPayloadParseError) { + * console.error('Failed to parse:', error.rawBody); + * response?.status(400).json({ error: 'Invalid JSON' }); + * } + * }); + * ``` + */ +export class WebhookPayloadParseError extends WebhookError { + constructor( + message: string, + public readonly rawBody?: string, + ) { + super(message); + this.name = 'WebhookPayloadParseError'; + } +} + diff --git a/src/main/resources/templates/node/webhook_event_types.ts.hbs b/src/main/resources/templates/node/webhook_event_types.ts.hbs index 4f99fbe..2f61fb1 100644 --- a/src/main/resources/templates/node/webhook_event_types.ts.hbs +++ b/src/main/resources/templates/node/webhook_event_types.ts.hbs @@ -9,7 +9,17 @@ export enum WebhookEventType { } /** - * @deprecated Use WebhookEventType instead. + * @deprecated Renamed to `WebhookEventType` for clarity. Use `WebhookEventType` instead. + * This alias will be removed in the next major version. + * + * @example + * // Before (deprecated) + * import { WebhookContentType } from 'chargebee'; + * if (event.event_type === WebhookContentType.SubscriptionCreated) { ... } + * + * // After (recommended) + * import { WebhookEventType } from 'chargebee'; + * if (event.event_type === WebhookEventType.SubscriptionCreated) { ... } */ export const WebhookContentType = WebhookEventType; diff --git a/src/main/resources/templates/node/webhook_handler.ts.hbs b/src/main/resources/templates/node/webhook_handler.ts.hbs new file mode 100644 index 0000000..a148762 --- /dev/null +++ b/src/main/resources/templates/node/webhook_handler.ts.hbs @@ -0,0 +1,649 @@ +import { EventEmitter } from 'node:events'; +import { WebhookEvent } from './content.js'; +import { basicAuthValidator } from './auth.js'; +import { WebhookEventType, WebhookContentType } from './eventType.js'; +import { + WebhookError, + WebhookAuthenticationError, + WebhookPayloadValidationError, + WebhookPayloadParseError, +} from './errors.js'; + +export { WebhookEventType, WebhookContentType }; +export { + WebhookError, + WebhookAuthenticationError, + WebhookPayloadValidationError, + WebhookPayloadParseError, +}; + +export type EventType = import('chargebee').EventTypeEnum; + +/** + * Context object passed to webhook event listeners. + * Wraps the event data with optional framework-specific request/response objects. + * + * @typeParam ReqT - Framework-specific request type (e.g., `express.Request`) + * @typeParam ResT - Framework-specific response type (e.g., `express.Response`) + * + * @example + * ```typescript + * handler.on('subscription_created', ({ event, request, response }) => { + * console.log('Event ID:', event.id); + * console.log('Event Type:', event.event_type); + * response?.status(200).send('OK'); + * }); + * ``` + */ +export interface WebhookContext { + /** The parsed webhook event from Chargebee */ + event: WebhookEvent; + /** Framework-specific request object (Express, Fastify, etc.) */ + request?: ReqT; + /** Framework-specific response object (Express, Fastify, etc.) */ + response?: ResT; +} + +/** + * Event map defining all available webhook events and their payload types. + * + * Includes: + * - All Chargebee event types (e.g., `subscription_created`, `customer_changed`) + * - `unhandled_event` - Emitted when an event has no registered listener + * - `error` - Emitted when an error occurs during webhook processing + * + * @typeParam ReqT - Framework-specific request type + * @typeParam ResT - Framework-specific response type + */ +export interface WebhookErrorContext { + /** Framework-specific request object (Express, Fastify, etc.) */ + request?: ReqT; + /** Framework-specific response object (Express, Fastify, etc.) */ + response?: ResT; +} + +export interface WebhookEventMap extends Record]> { + unhandled_event: [WebhookContext]; + error: [Error, WebhookErrorContext]; +} + +/** + * Type for webhook event listener functions. + * + * @typeParam ReqT - Framework-specific request type + * @typeParam ResT - Framework-specific response type + * @typeParam K - The specific event type key from WebhookEventMap + */ +export type WebhookEventListener> = ( + ...args: WebhookEventMap[K] +) => Promise | void; + +/** + * Validator function type for authenticating webhook requests. + * + * Receives HTTP headers and should throw an error if authentication fails. + * Can be synchronous or asynchronous (e.g., for database lookups). + * + * @param headers - HTTP headers from the incoming request + * @throws Error if authentication fails + * + * @example + * ```typescript + * // Custom validator example + * const customValidator: RequestValidator = async (headers) => { + * const apiKey = headers['x-api-key']; + * if (apiKey !== process.env.WEBHOOK_API_KEY) { + * throw new Error('Invalid API key'); + * } + * }; + * ``` + */ +export type RequestValidator = ( + headers: Record, +) => void | Promise; + +/** + * Configuration options for creating a WebhookHandler instance. + */ +export interface WebhookHandlerOptions { + /** + * Optional validator function to authenticate incoming webhook requests. + * + * Common use cases: + * - Basic Auth validation using `basicAuthValidator()` + * - Custom header validation (API keys, signatures) + * - Async validation against a database + * + * If not provided, a warning will be logged on first webhook handling. + * Chargebee supports no-auth webhooks, so this is optional but recommended. + * + * @example + * ```typescript + * // Using built-in Basic Auth validator + * const handler = createHandler({ + * requestValidator: basicAuthValidator((u, p) => u === 'user' && p === 'pass'), + * }); + * ``` + */ + requestValidator?: RequestValidator; +} + +/** + * Options for the {@link WebhookHandler.handle} method. + * + * @typeParam ReqT - Framework-specific request type (e.g., `express.Request`) + * @typeParam ResT - Framework-specific response type (e.g., `express.Response`) + */ +export interface HandleOptions { + /** + * The raw request body as a string or pre-parsed JSON object. + * If a string, it will be parsed as JSON. + */ + body: string | object; + /** + * HTTP headers from the incoming request. + * Required if a `requestValidator` is configured; otherwise authentication is skipped. + */ + headers?: Record; + /** + * Framework-specific request object passed through to event handlers. + * Useful for accessing additional request properties in handlers. + */ + request?: ReqT; + /** + * Framework-specific response object passed through to event handlers. + * Handlers should use this to send HTTP responses back to Chargebee. + * + * @remarks + * **Important:** Always send a response (e.g., `response.status(200).send('OK')`) + * to prevent Chargebee from retrying the webhook. + */ + response?: ResT; +} + +/** + * Webhook handler for processing Chargebee webhook events. + * + * Extends Node.js `EventEmitter` to provide a familiar, event-driven API for + * handling webhooks. Supports type-safe event listeners with full TypeScript + * autocomplete for all Chargebee event types. + * + * @typeParam ReqT - Framework-specific request type (e.g., `express.Request`) + * @typeParam ResT - Framework-specific response type (e.g., `express.Response`) + * + * @remarks + * **Lifecycle Warning:** Event listeners persist for the lifetime of the handler + * instance. Register handlers once at application startup, not per-request. + * + * @example Basic Usage with Express + * ```typescript + * import express from 'express'; + * import { createHandler, basicAuthValidator } from 'chargebee/webhook'; + * + * const app = express(); + * app.use(express.json()); + * + * // Create handler with Basic Auth + * const webhookHandler = createHandler({ + * requestValidator: basicAuthValidator((u, p) => u === 'admin' && p === 'secret'), + * }); + * + * // Register event listeners ONCE at startup (not per-request!) + * webhookHandler.on('subscription_created', async ({ event, response }) => { + * console.log('New subscription:', event.content.subscription.id); + * response?.status(200).send('OK'); + * }); + * + * webhookHandler.on('error', (error, { response }) => { + * if (error instanceof WebhookAuthenticationError) { + * response?.status(401).send('Unauthorized'); + * } else if (error instanceof WebhookPayloadValidationError || error instanceof WebhookPayloadParseError) { + * response?.status(400).send('Bad Request'); + * } else { + * response?.status(500).send('Internal Server Error'); + * } + * }); + * + * // Route handler + * app.post('/webhooks', async (req, res) => { + * await webhookHandler.handle({ + * body: req.body, + * headers: req.headers, + * request: req, + * response: res, + * }); + * }); + * ``` + * + * @example Available Event Types + * ```typescript + * // Subscription events + * handler.on('subscription_created', ({ event }) => { ... }); + * handler.on('subscription_changed', ({ event }) => { ... }); + * handler.on('subscription_cancelled', ({ event }) => { ... }); + * + * // Customer events + * handler.on('customer_created', ({ event }) => { ... }); + * handler.on('customer_changed', ({ event }) => { ... }); + * + * // Payment events + * handler.on('payment_succeeded', ({ event }) => { ... }); + * handler.on('payment_failed', ({ event }) => { ... }); + * + * // Special events + * handler.on('unhandled_event', ({ event }) => { + * console.log('Unhandled event type:', event.event_type); + * }); + * + * handler.on('error', (error, { response }) => { + * if (error instanceof WebhookAuthenticationError) { + * response?.status(401).send('Unauthorized'); + * } else if (error instanceof WebhookPayloadValidationError || error instanceof WebhookPayloadParseError) { + * response?.status(400).send('Bad Request'); + * } else { + * response?.status(500).send('Internal Server Error'); + * } + * }); + * ``` + * + * @example Async Handlers + * ```typescript + * // Handlers can be async - errors are captured and emitted to 'error' event + * handler.on('subscription_created', async ({ event, response }) => { + * await saveToDatabase(event.content.subscription); + * await sendWelcomeEmail(event.content.customer); + * response?.status(200).send('OK'); + * }); + * ``` + */ +export class WebhookHandler extends EventEmitter> { + private _requestValidator?: RequestValidator; + private _noAuthWarningShown = false; + + /** + * Creates a new WebhookHandler instance. + * + * @param options - Optional configuration options + * + * @example + * ```typescript + * // Without authentication (not recommended for production) + * const handler = new WebhookHandler(); + * + * // With Basic Auth + * const handler = new WebhookHandler({ + * requestValidator: basicAuthValidator((u, p) => u === 'user' && p === 'pass'), + * }); + * ``` + */ + constructor(options?: WebhookHandlerOptions) { + super({ captureRejections: true }); + this._requestValidator = options?.requestValidator; + } + + /** + * Gets the current request validator function. + * + * @returns The configured validator or `undefined` if no authentication is set + * + * @example + * ```typescript + * if (handler.requestValidator) { + * console.log('Authentication is configured'); + * } + * ``` + */ + get requestValidator(): RequestValidator | undefined { + return this._requestValidator; + } + + /** + * Sets or updates the request validator function. + * + * Use this to configure authentication after handler creation, + * or to change validators at runtime. + * + * @param validator - The validator function, or `undefined` to disable authentication + * + * @example + * ```typescript + * // Set up Basic Auth after creation + * handler.requestValidator = basicAuthValidator((u, p) => u === 'admin' && p === 'secret'); + * + * // Custom header validation + * handler.requestValidator = (headers) => { + * if (headers['x-webhook-secret'] !== process.env.WEBHOOK_SECRET) { + * throw new Error('Invalid webhook secret'); + * } + * }; + * + * // Disable authentication (not recommended) + * handler.requestValidator = undefined; + * ``` + */ + set requestValidator(validator: RequestValidator | undefined) { + this._requestValidator = validator; + } + + /** + * Registers an event listener for a specific webhook event type. + * + * This method is inherited from Node.js `EventEmitter` but is documented here + * for clarity on available Chargebee webhook events. + * + * @param eventName - The Chargebee event type to listen for (e.g., `'subscription_created'`) + * @param listener - Callback function invoked when the event occurs + * @returns This handler instance for method chaining + * + * @remarks + * **Memory Leak Warning:** Listeners persist for the handler's lifetime. + * Always register listeners once at application startup, never inside + * request handlers or loops. + * + * @example Available Events + * ```typescript + * // Chargebee business events + * handler.on('subscription_created', ({ event, response }) => { ... }); + * handler.on('subscription_changed', ({ event, response }) => { ... }); + * handler.on('subscription_cancelled', ({ event, response }) => { ... }); + * handler.on('customer_created', ({ event, response }) => { ... }); + * handler.on('payment_succeeded', ({ event, response }) => { ... }); + * handler.on('invoice_generated', ({ event, response }) => { ... }); + * // ... and many more - see WebhookEventType enum for full list + * + * // Special events + * handler.on('unhandled_event', ({ event }) => { + * // Called when no listener exists for the event type + * console.log('Unhandled:', event.event_type); + * }); + * + * handler.on('error', (error, { response }) => { + * // Called on validation errors, parse errors, or handler errors + * if (error instanceof WebhookAuthenticationError) { + * response?.status(401).send('Unauthorized'); + * } else if (error instanceof WebhookPayloadValidationError || error instanceof WebhookPayloadParseError) { + * response?.status(400).send('Bad Request'); + * } else { + * response?.status(500).send('Internal Server Error'); + * } + * }); + * ``` + * + * @example Correct Usage + * ```typescript + * // ✅ GOOD: Register once at startup + * const handler = createHandler(); + * handler.on('subscription_created', handleSubscription); + * handler.on('error', handleError); + * + * app.post('/webhooks', async (req, res) => { + * await handler.handle({ body: req.body, headers: req.headers, response: res }); + * }); + * ``` + * + * @example Incorrect Usage + * ```typescript + * // ❌ BAD: Don't register inside request handlers - causes memory leak! + * app.post('/webhooks', async (req, res) => { + * handler.on('subscription_created', async () => { ... }); // Memory leak! + * await handler.handle({ ... }); + * }); + * ``` + */ + // Note: on() is inherited from EventEmitter with proper typing via WebhookEventMap + + /** + * Handles an incoming webhook request from Chargebee. + * + * This method: + * 1. Validates the request using the configured `requestValidator` (if any) + * 2. Parses the request body (if it's a string) + * 3. Validates required fields (`event_type`, `id`) + * 4. Emits the appropriate event to registered listeners + * + * @param options - The webhook request options + * @returns A promise that resolves when the event has been emitted + * + * @throws Error if no `error` listener is registered and an error occurs + * + * @remarks + * **Async Behavior:** This method emits events but does not wait for async + * listeners to complete. Errors in async listeners are captured via + * `captureRejections` and emitted to the `error` event. + * + * **Response Handling:** The handler does NOT automatically send HTTP responses. + * Your event listeners must call `response.status(200).send('OK')` or similar. + * + * @example Express Integration + * ```typescript + * app.post('/webhooks', async (req, res) => { + * try { + * await handler.handle({ + * body: req.body, + * headers: req.headers, + * request: req, + * response: res, + * }); + * } catch (error) { + * // Only reached if no 'error' listener is registered + * res.status(500).send('Internal error'); + * } + * }); + * ``` + * + * @example Fastify Integration + * ```typescript + * fastify.post('/webhooks', async (request, reply) => { + * await handler.handle({ + * body: request.body, + * headers: request.headers, + * request, + * response: reply, + * }); + * }); + * ``` + * + * @example Raw Node.js HTTP + * ```typescript + * http.createServer(async (req, res) => { + * if (req.method === 'POST' && req.url === '/webhooks') { + * const body = await getBody(req); + * await handler.handle({ body, headers: req.headers, response: res }); + * } + * }); + * ``` + */ + async handle(options: HandleOptions): Promise { + const { body, headers, request, response } = options; + try { + if (this._requestValidator) { + if (!headers) { + console.warn( + '[chargebee] Warning: Request validator is configured but no headers were passed. ' + + 'Authentication check skipped. If this is intentional (no-auth webhook), ' + + 'you can remove the requestValidator or ignore this warning.', + ); + } else { + await this._requestValidator(headers); + } + } else if (!this._noAuthWarningShown) { + this._noAuthWarningShown = true; + console.warn( + '[chargebee] Warning: No webhook authentication configured. ' + + 'Consider using basicAuthValidator() or a custom requestValidator for production. ' + + 'See: https://www.chargebee.com/docs/billing/2.0/site-configuration/webhook_settings#basic-authentication', + ); + } + + let event: WebhookEvent; + try { + event = typeof body === 'string' ? JSON.parse(body) : (body as WebhookEvent); + } catch (parseErr) { + const parseError = parseErr instanceof Error ? parseErr : new Error(String(parseErr)); + throw new WebhookPayloadParseError( + `Failed to parse webhook body: ${parseError.message}`, + typeof body === 'string' ? body : undefined, + ); + } + + // Validate required fields + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw new WebhookPayloadValidationError('Invalid webhook payload: body must be a JSON object'); + } + if (!event.event_type || typeof event.event_type !== 'string') { + throw new WebhookPayloadValidationError('Invalid webhook payload: missing or invalid event_type'); + } + if (!event.id) { + throw new WebhookPayloadValidationError('Invalid webhook payload: missing event id'); + } + + const context: WebhookContext = { + event, + request, + response, + }; + + const eventType = event.event_type as keyof WebhookEventMap; + + if (this.listenerCount(eventType) > 0) { + this.emit(eventType, context); + } else { + this.emit('unhandled_event', context); + } + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + if (this.listenerCount('error') === 0) { + console.warn('[chargebee] Webhook error with no handler:', error.message); + throw error; + } + this.emit('error', error, { request, response }); + } + } +} + +/** + * Creates a new WebhookHandler with custom configuration. + * + * This is the recommended factory function for creating webhook handlers. + * Use this when you need explicit control over authentication configuration. + * + * @typeParam ReqT - Framework-specific request type (e.g., `express.Request`) + * @typeParam ResT - Framework-specific response type (e.g., `express.Response`) + * + * @param options - Optional configuration for the handler + * @returns A new WebhookHandler instance + * + * @remarks + * For multi-route or multi-tenant scenarios, create separate handler instances + * to maintain isolation and avoid event listener conflicts. + * + * @example Basic Auth Configuration + * ```typescript + * import { createHandler, basicAuthValidator } from 'chargebee/webhook'; + * + * const handler = createHandler({ + * requestValidator: basicAuthValidator((username, password) => { + * return username === 'admin' && password === 'secret'; + * }), + * }); + * ``` + * + * @example Custom Authentication + * ```typescript + * const handler = createHandler({ + * requestValidator: async (headers) => { + * const signature = headers['x-chargebee-signature']; + * const isValid = await verifySignature(signature); + * if (!isValid) throw new Error('Invalid signature'); + * }, + * }); + * ``` + * + * @example Multi-Route Setup + * ```typescript + * // Separate handlers for different webhook endpoints + * const billingHandler = createHandler({ ... }); + * const notificationHandler = createHandler({ ... }); + * + * billingHandler.on('subscription_created', handleBillingEvent); + * notificationHandler.on('subscription_created', sendNotification); + * + * app.post('/webhooks/billing', (req, res) => billingHandler.handle({ ... })); + * app.post('/webhooks/notifications', (req, res) => notificationHandler.handle({ ... })); + * ``` + * + * @example Without Authentication (Development Only) + * ```typescript + * // ⚠️ Not recommended for production + * const handler = createHandler(); + * ``` + */ +export function createHandler( + options?: WebhookHandlerOptions, +): WebhookHandler { + return new WebhookHandler(options); +} + +/** + * Creates a WebhookHandler with auto-configured Basic Auth from environment variables. + * + * This is a convenience function that automatically configures Basic Auth + * when the following environment variables are set: + * - `CHARGEBEE_WEBHOOK_USERNAME` + * - `CHARGEBEE_WEBHOOK_PASSWORD` + * + * If these environment variables are not set, the handler is created without + * authentication (a warning will be logged on first webhook handling). + * + * @typeParam ReqT - Framework-specific request type (e.g., `express.Request`) + * @typeParam ResT - Framework-specific response type (e.g., `express.Response`) + * + * @returns A new WebhookHandler instance with optional auto-configured auth + * + * @remarks + * This function is used internally by `chargebee.webhooks` to provide a + * pre-configured handler on the Chargebee instance. + * + * @example Environment-Based Setup + * ```bash + * # .env file + * CHARGEBEE_WEBHOOK_USERNAME=webhook_user + * CHARGEBEE_WEBHOOK_PASSWORD=webhook_secret + * ``` + * + * ```typescript + * import { createDefaultHandler } from 'chargebee/webhook'; + * + * // Auth is automatically configured from env vars + * const handler = createDefaultHandler(); + * + * handler.on('subscription_created', ({ event, response }) => { + * console.log('Subscription:', event.content.subscription.id); + * response?.status(200).send('OK'); + * }); + * ``` + * + * @example Overriding Auto-Configured Auth + * ```typescript + * const handler = createDefaultHandler(); + * + * // Override with custom validator if needed + * handler.requestValidator = myCustomValidator; + * ``` + * + * @see {@link createHandler} for explicit configuration without environment variables + */ +export function createDefaultHandler(): WebhookHandler { + const handler = new WebhookHandler(); + const username = process.env.CHARGEBEE_WEBHOOK_USERNAME; + const password = process.env.CHARGEBEE_WEBHOOK_PASSWORD; + if (username && password) { + handler.requestValidator = basicAuthValidator( + (u, p) => u === username && p === password, + ); + } + return handler; +} + +export type { WebhookEvent } from './content.js'; +export { basicAuthValidator, type CredentialValidator } from './auth.js'; diff --git a/src/main/resources/templates/ts/chargebee.ts.hbs b/src/main/resources/templates/ts/chargebee.ts.hbs index b300913..b113f8c 100644 --- a/src/main/resources/templates/ts/chargebee.ts.hbs +++ b/src/main/resources/templates/ts/chargebee.ts.hbs @@ -18,7 +18,7 @@ export class ChargeBee { ChargeBee._env.timeout = timeout; } {{#each resources}} - get {{pascalCaseToSnakeCase name}}() { + get {{pascalCaseToSnakeCaseAndPluralize name}}() { return resources.{{name}}; }{{/each}} } diff --git a/src/main/resources/templates/ts/typings/v3/index.d.ts.hbs b/src/main/resources/templates/ts/typings/v3/index.d.ts.hbs index dde5276..d94cf87 100644 --- a/src/main/resources/templates/ts/typings/v3/index.d.ts.hbs +++ b/src/main/resources/templates/ts/typings/v3/index.d.ts.hbs @@ -82,5 +82,157 @@ declare module 'chargebee' { constructor(config: Config); {{#each resources}}{{#if hasActions}} {{snakeCaseToCamelCaseAndSingularize id}}: {{name}}.{{name}}Resource; {{/if}}{{/each}} + /** Webhook handler instance with auto-configured Basic Auth (if env vars are set) */ + webhooks: WebhookHandler & { + /** Create a new typed webhook handler instance */ + createHandler(options?: WebhookHandlerOptions): WebhookHandler; + }; } + + // Webhook Handler Types + export type WebhookEventName = EventTypeEnum | 'unhandled_event'; + export type WebhookEventTypeValue = `${WebhookEventType}`; + /** + * @deprecated Renamed to `WebhookEventTypeValue` for clarity. Use `WebhookEventTypeValue` instead. + * This alias will be removed in the next major version. + */ + export type WebhookContentTypeValue = WebhookEventTypeValue; + + /** + * Context object passed to webhook event listeners. + * Wraps the event data with optional framework-specific request/response objects. + */ + export interface WebhookContext { + /** The parsed webhook event from Chargebee */ + event: WebhookEvent; + /** Framework-specific request object (Express, Fastify, etc.) */ + request?: ReqT; + /** Framework-specific response object (Express, Fastify, etc.) */ + response?: ResT; + } + + /** + * Context object passed to webhook error listeners. + * Contains the request/response objects so errors can be handled appropriately. + */ + export interface WebhookErrorContext { + /** Framework-specific request object (Express, Fastify, etc.) */ + request?: ReqT; + /** Framework-specific response object (Express, Fastify, etc.) */ + response?: ResT; + } + + /** + * Validator function type for authenticating webhook requests. + * Can be synchronous or asynchronous. + */ + export type RequestValidator = ( + headers: Record, + ) => void | Promise; + + /** + * Configuration options for WebhookHandler. + */ + export interface WebhookHandlerOptions { + /** + * Optional validator function to authenticate incoming webhook requests. + * Typically used for Basic Auth validation. + * Can be sync or async - throw an error to reject the request. + */ + requestValidator?: RequestValidator; + } + + /** + * Options for the handle() method. + */ + export interface HandleOptions { + /** The raw request body (string) or pre-parsed object */ + body: string | object; + /** Optional HTTP headers for validation */ + headers?: Record; + /** Optional framework-specific request object (Express, Fastify, etc.) */ + request?: ReqT; + /** Optional framework-specific response object (Express, Fastify, etc.) */ + response?: ResT; + } + + export type WebhookEventListener = (context: WebhookContext & { event: WebhookEvent }) => Promise | void; + export type WebhookErrorListener = (error: Error, context: WebhookErrorContext) => Promise | void; + + // Helper type to map string literal to enum member + type StringToWebhookEventType = { + [K in WebhookEventType]: `${K}` extends S ? K : never + }[WebhookEventType]; + + export interface WebhookHandler { + on(eventName: T, listener: WebhookEventListener): this; + on(eventName: S, listener: WebhookEventListener>): this; + on(eventName: 'unhandled_event', listener: WebhookEventListener): this; + on(eventName: 'error', listener: WebhookErrorListener): this; + once(eventName: T, listener: WebhookEventListener): this; + once(eventName: S, listener: WebhookEventListener>): this; + once(eventName: 'unhandled_event', listener: WebhookEventListener): this; + once(eventName: 'error', listener: WebhookErrorListener): this; + off(eventName: T, listener: WebhookEventListener): this; + off(eventName: S, listener: WebhookEventListener>): this; + off(eventName: 'unhandled_event', listener: WebhookEventListener): this; + off(eventName: 'error', listener: WebhookErrorListener): this; + handle(options: HandleOptions): Promise; + requestValidator: RequestValidator | undefined; + } + + // Webhook Auth + /** + * Credential validator function type. + * Can be synchronous or asynchronous (e.g., for database lookups). + */ + export type CredentialValidator = ( + username: string, + password: string, + ) => boolean | Promise; + + /** + * Creates a Basic Auth validator for webhook requests. + */ + export function basicAuthValidator( + validateCredentials: CredentialValidator, + ): (headers: Record) => Promise; + + // Webhook Error Classes + /** + * Base class for all webhook-related errors. + */ + export class WebhookError extends Error { + constructor(message: string); + name: string; + } + + /** + * Authentication error thrown when webhook request authentication fails. + * Typically maps to HTTP 401 Unauthorized. + */ + export class WebhookAuthenticationError extends WebhookError { + constructor(message: string); + name: string; + } + + /** + * Payload validation error thrown when the webhook payload structure is invalid. + * Typically maps to HTTP 400 Bad Request. + */ + export class WebhookPayloadValidationError extends WebhookError { + constructor(message: string); + name: string; + } + + /** + * JSON parsing error thrown when the webhook body cannot be parsed as JSON. + * Typically maps to HTTP 400 Bad Request. + */ + export class WebhookPayloadParseError extends WebhookError { + constructor(message: string, rawBody?: string); + name: string; + readonly rawBody?: string; + } + } \ No newline at end of file diff --git a/src/main/resources/templates/ts/typings/v3/webhookContent.d.ts.hbs b/src/main/resources/templates/ts/typings/v3/webhookContent.d.ts.hbs index eb0615f..139c8c4 100644 --- a/src/main/resources/templates/ts/typings/v3/webhookContent.d.ts.hbs +++ b/src/main/resources/templates/ts/typings/v3/webhookContent.d.ts.hbs @@ -7,9 +7,9 @@ declare module 'chargebee' { export enum WebhookEventType { {{#each this}} {{snakeCaseToPascalCase type}} = '{{type}}',{{/each}} } - /** - * @deprecated Use WebhookEventType instead. + * @deprecated Renamed to `WebhookEventType` for clarity. Use `WebhookEventType` instead. + * This alias will be removed in the next major version. */ export import WebhookContentType = WebhookEventType; diff --git a/src/test/java/com/chargebee/sdk/ts/samples/chargebee_1.tsf b/src/test/java/com/chargebee/sdk/ts/samples/chargebee_1.tsf index 1818184..b887c8a 100644 --- a/src/test/java/com/chargebee/sdk/ts/samples/chargebee_1.tsf +++ b/src/test/java/com/chargebee/sdk/ts/samples/chargebee_1.tsf @@ -18,7 +18,7 @@ export class ChargeBee { ChargeBee._env.timeout = timeout; } - get subscription() { + get subscriptions() { return resources.Subscription; } } diff --git a/src/test/java/com/chargebee/sdk/ts/samples/chargebee_2.tsf b/src/test/java/com/chargebee/sdk/ts/samples/chargebee_2.tsf index e2fd311..25c49cc 100644 --- a/src/test/java/com/chargebee/sdk/ts/samples/chargebee_2.tsf +++ b/src/test/java/com/chargebee/sdk/ts/samples/chargebee_2.tsf @@ -19,10 +19,10 @@ export class ChargeBee { ChargeBee._env.timeout = timeout; } - get subscription() { + get subscriptions() { return resources.Subscription; } - get contract_term() { + get contract_terms() { return resources.ContractTerm; } } diff --git a/src/test/java/com/chargebee/sdk/ts/typings/TypeScriptTypingV3Tests.java b/src/test/java/com/chargebee/sdk/ts/typings/TypeScriptTypingV3Tests.java index 3c66b27..c30d914 100644 --- a/src/test/java/com/chargebee/sdk/ts/typings/TypeScriptTypingV3Tests.java +++ b/src/test/java/com/chargebee/sdk/ts/typings/TypeScriptTypingV3Tests.java @@ -90,7 +90,7 @@ void shouldWriteIndexFile() throws IOException { fileOps.get(2), "/tmp", "index.d.ts", - "declare__module__'chargebee'__{__export__type__Config__=__{__/**__*__@apiKey__api__key__for__the__site.__*/__apiKey:__string;__/**__*__@site__api__site__name.__*/__site:__string;__/**__*__@apiPath__this__value__indicates__the__api__version,__default__value__is__/api/v2.__*/__apiPath?:__'/api/v2'__|__'/api/v1';__/**__*__@timeout__client__side__request__timeout__in__milliseconds,__default__value__is__80000ms.__*/__timeout?:__number;__/**__*__@port__url__port__*/__port?:__number;__/**__*__@timemachineWaitInMillis__time__interval__at__which__two__subsequent__retrieve__timemachine__call__in__milliseconds,__default__value__is__3000ms.__*/__timemachineWaitInMillis?:__number;__/**__*__@exportWaitInMillis__time__interval__at__which__two__subsequent__retrieve__export__call__in__milliseconds,__default__value__is__3000ms.__*/__exportWaitInMillis?:__number;__/**__*__@protocol__http__protocol,__default__value__is__https__*/__protocol?:__'https'__|__'http';__/**__*__@hostSuffix__url__host__suffix,__default__value__is__.chargebee.com__*/__hostSuffix?:__string;__/**__*__@retryConfig__retry__configuration__for__the__client,__default__value__is__{__enabled:__false,__maxRetries:__3,__delayMs:__1000,__retryOn:__[500,__502,__503,__504]}__*/__retryConfig?:__RetryConfig;__/**__*__@enableDebugLogs__whether__to__enable__debug__logs,__default__value__is__false__*/__enableDebugLogs?:__boolean;__/**__*__@userAgentSuffix__optional__string__appended__to__the__User-Agent__header__for__additional__logging__*/__userAgentSuffix?:__string;__/**__*__@httpClient__optional__http__client__implementation,__default__http__client__will__be__used__if__not__provided__*/__httpClient?:__HttpClientInterface;__};__export__interface__HttpClientInterface__{__makeApiRequest:__(request:__Request,__timeout:__number)__=>__Promise;__}__export__type__RetryConfig__=__{__/**__*__@enabled__whether__to__enable__retry__logic,__default__value__is__false__*__@maxRetries__maximum__number__of__retries,__default__value__is__3__*__@delayMs__delay__in__milliseconds__between__retries,__default__value__is__1000ms__*__@retryOn__array__of__HTTP__status__codes__to__retry__on,__default__value__is__[500,__502,__503,__504]__*/__enabled?:__boolean;__maxRetries?:__number;__delayMs?:__number;__retryOn?:__Array;__};__export__default__class__Chargebee__{__constructor(config:__Config);__}__}"); + "declare__module__'chargebee'__{__export__type__Config__=__{__/**__*__@apiKey__api__key__for__the__site.__*/__apiKey:__string;__/**__*__@site__api__site__name.__*/__site:__string;__/**__*__@apiPath__this__value__indicates__the__api__version,__default__value__is__/api/v2.__*/__apiPath?:__'/api/v2'__|__'/api/v1';__/**__*__@timeout__client__side__request__timeout__in__milliseconds,__default__value__is__80000ms.__*/__timeout?:__number;__/**__*__@port__url__port__*/__port?:__number;__/**__*__@timemachineWaitInMillis__time__interval__at__which__two__subsequent__retrieve__timemachine__call__in__milliseconds,__default__value__is__3000ms.__*/__timemachineWaitInMillis?:__number;__/**__*__@exportWaitInMillis__time__interval__at__which__two__subsequent__retrieve__export__call__in__milliseconds,__default__value__is__3000ms.__*/__exportWaitInMillis?:__number;__/**__*__@protocol__http__protocol,__default__value__is__https__*/__protocol?:__'https'__|__'http';__/**__*__@hostSuffix__url__host__suffix,__default__value__is__.chargebee.com__*/__hostSuffix?:__string;__/**__*__@retryConfig__retry__configuration__for__the__client,__default__value__is__{__enabled:__false,__maxRetries:__3,__delayMs:__1000,__retryOn:__[500,__502,__503,__504]}__*/__retryConfig?:__RetryConfig;__/**__*__@enableDebugLogs__whether__to__enable__debug__logs,__default__value__is__false__*/__enableDebugLogs?:__boolean;__/**__*__@userAgentSuffix__optional__string__appended__to__the__User-Agent__header__for__additional__logging__*/__userAgentSuffix?:__string;__/**__*__@httpClient__optional__http__client__implementation,__default__http__client__will__be__used__if__not__provided__*/__httpClient?:__HttpClientInterface;__};__export__interface__HttpClientInterface__{__makeApiRequest:__(request:__Request,__timeout:__number)__=>__Promise;__}__export__type__RetryConfig__=__{__/**__*__@enabled__whether__to__enable__retry__logic,__default__value__is__false__*__@maxRetries__maximum__number__of__retries,__default__value__is__3__*__@delayMs__delay__in__milliseconds__between__retries,__default__value__is__1000ms__*__@retryOn__array__of__HTTP__status__codes__to__retry__on,__default__value__is__[500,__502,__503,__504]__*/__enabled?:__boolean;__maxRetries?:__number;__delayMs?:__number;__retryOn?:__Array;__};__export__default__class__Chargebee__{__constructor(config:__Config);__/**__Webhook__handler__instance__with__auto-configured__Basic__Auth__(if__env__vars__are__set)__*/__webhooks:__WebhookHandler__&__{__/**__Create__a__new__typed__webhook__handler__instance__*/__createHandler(options?:__WebhookHandlerOptions):__WebhookHandler;__};__}__//__Webhook__Handler__Types__export__type__WebhookEventName__=__EventTypeEnum__|__'unhandled_event';__export__type__WebhookEventTypeValue__=__`${WebhookEventType}`;__/**__*__@deprecated__Renamed__to__`WebhookEventTypeValue`__for__clarity.__Use__`WebhookEventTypeValue`__instead.__*__This__alias__will__be__removed__in__the__next__major__version.__*/__export__type__WebhookContentTypeValue__=__WebhookEventTypeValue;__/**__*__Context__object__passed__to__webhook__event__listeners.__*__Wraps__the__event__data__with__optional__framework-specific__request/response__objects.__*/__export__interface__WebhookContext__{__/**__The__parsed__webhook__event__from__Chargebee__*/__event:__WebhookEvent;__/**__Framework-specific__request__object__(Express,__Fastify,__etc.)__*/__request?:__ReqT;__/**__Framework-specific__response__object__(Express,__Fastify,__etc.)__*/__response?:__ResT;__}__/**__*__Context__object__passed__to__webhook__error__listeners.__*__Contains__the__request/response__objects__so__errors__can__be__handled__appropriately.__*/__export__interface__WebhookErrorContext__{__/**__Framework-specific__request__object__(Express,__Fastify,__etc.)__*/__request?:__ReqT;__/**__Framework-specific__response__object__(Express,__Fastify,__etc.)__*/__response?:__ResT;__}__/**__*__Validator__function__type__for__authenticating__webhook__requests.__*__Can__be__synchronous__or__asynchronous.__*/__export__type__RequestValidator__=__(__headers:__Record,__)__=>__void__|__Promise;__/**__*__Configuration__options__for__WebhookHandler.__*/__export__interface__WebhookHandlerOptions__{__/**__*__Optional__validator__function__to__authenticate__incoming__webhook__requests.__*__Typically__used__for__Basic__Auth__validation.__*__Can__be__sync__or__async__-__throw__an__error__to__reject__the__request.__*/__requestValidator?:__RequestValidator;__}__/**__*__Options__for__the__handle()__method.__*/__export__interface__HandleOptions__{__/**__The__raw__request__body__(string)__or__pre-parsed__object__*/__body:__string__|__object;__/**__Optional__HTTP__headers__for__validation__*/__headers?:__Record;__/**__Optional__framework-specific__request__object__(Express,__Fastify,__etc.)__*/__request?:__ReqT;__/**__Optional__framework-specific__response__object__(Express,__Fastify,__etc.)__*/__response?:__ResT;__}__export__type__WebhookEventListener__=__(context:__WebhookContext__&__{__event:__WebhookEvent__})__=>__Promise__|__void;__export__type__WebhookErrorListener__=__(error:__Error,__context:__WebhookErrorContext)__=>__Promise__|__void;__//__Helper__type__to__map__string__literal__to__enum__member__type__StringToWebhookEventType__=__{__[K__in__WebhookEventType]:__`${K}`__extends__S__?__K__:__never__}[WebhookEventType];__export__interface__WebhookHandler__{__on(eventName:__T,__listener:__WebhookEventListener):__this;__on(eventName:__S,__listener:__WebhookEventListener>):__this;__on(eventName:__'unhandled_event',__listener:__WebhookEventListener):__this;__on(eventName:__'error',__listener:__WebhookErrorListener):__this;__once(eventName:__T,__listener:__WebhookEventListener):__this;__once(eventName:__S,__listener:__WebhookEventListener>):__this;__once(eventName:__'unhandled_event',__listener:__WebhookEventListener):__this;__once(eventName:__'error',__listener:__WebhookErrorListener):__this;__off(eventName:__T,__listener:__WebhookEventListener):__this;__off(eventName:__S,__listener:__WebhookEventListener>):__this;__off(eventName:__'unhandled_event',__listener:__WebhookEventListener):__this;__off(eventName:__'error',__listener:__WebhookErrorListener):__this;__handle(options:__HandleOptions):__Promise;__requestValidator:__RequestValidator__|__undefined;__}__//__Webhook__Auth__/**__*__Credential__validator__function__type.__*__Can__be__synchronous__or__asynchronous__(e.g.,__for__database__lookups).__*/__export__type__CredentialValidator__=__(__username:__string,__password:__string,__)__=>__boolean__|__Promise;__/**__*__Creates__a__Basic__Auth__validator__for__webhook__requests.__*/__export__function__basicAuthValidator(__validateCredentials:__CredentialValidator,__):__(headers:__Record)__=>__Promise;__//__Webhook__Error__Classes__/**__*__Base__class__for__all__webhook-related__errors.__*/__export__class__WebhookError__extends__Error__{__constructor(message:__string);__name:__string;__}__/**__*__Authentication__error__thrown__when__webhook__request__authentication__fails.__*__Typically__maps__to__HTTP__401__Unauthorized.__*/__export__class__WebhookAuthenticationError__extends__WebhookError__{__constructor(message:__string);__name:__string;__}__/**__*__Payload__validation__error__thrown__when__the__webhook__payload__structure__is__invalid.__*__Typically__maps__to__HTTP__400__Bad__Request.__*/__export__class__WebhookPayloadValidationError__extends__WebhookError__{__constructor(message:__string);__name:__string;__}__/**__*__JSON__parsing__error__thrown__when__the__webhook__body__cannot__be__parsed__as__JSON.__*__Typically__maps__to__HTTP__400__Bad__Request.__*/__export__class__WebhookPayloadParseError__extends__WebhookError__{__constructor(message:__string,__rawBody?:__string);__name:__string;__readonly__rawBody?:__string;__}__}"); } @Test @@ -374,7 +374,7 @@ void shouldTurnGetOperationIntoAMethodInResource() throws IOException { fileOps.get(4), "/tmp", "index.d.ts", - "///__///__declare__module__'chargebee'__{__export__type__Config__=__{__/**__*__@apiKey__api__key__for__the__site.__*/__apiKey:__string;__/**__*__@site__api__site__name.__*/__site:__string;__/**__*__@apiPath__this__value__indicates__the__api__version,__default__value__is__/api/v2.__*/__apiPath?:__'/api/v2'__|__'/api/v1';__/**__*__@timeout__client__side__request__timeout__in__milliseconds,__default__value__is__80000ms.__*/__timeout?:__number;__/**__*__@port__url__port__*/__port?:__number;__/**__*__@timemachineWaitInMillis__time__interval__at__which__two__subsequent__retrieve__timemachine__call__in__milliseconds,__default__value__is__3000ms.__*/__timemachineWaitInMillis?:__number;__/**__*__@exportWaitInMillis__time__interval__at__which__two__subsequent__retrieve__export__call__in__milliseconds,__default__value__is__3000ms.__*/__exportWaitInMillis?:__number;__/**__*__@protocol__http__protocol,__default__value__is__https__*/__protocol?:__'https'__|__'http';__/**__*__@hostSuffix__url__host__suffix,__default__value__is__.chargebee.com__*/__hostSuffix?:__string;__/**__*__@retryConfig__retry__configuration__for__the__client,__default__value__is__{__enabled:__false,__maxRetries:__3,__delayMs:__1000,__retryOn:__[500,__502,__503,__504]}__*/__retryConfig?:__RetryConfig;__/**__*__@enableDebugLogs__whether__to__enable__debug__logs,__default__value__is__false__*/__enableDebugLogs?:__boolean;__/**__*__@userAgentSuffix__optional__string__appended__to__the__User-Agent__header__for__additional__logging__*/__userAgentSuffix?:__string;__/**__*__@httpClient__optional__http__client__implementation,__default__http__client__will__be__used__if__not__provided__*/__httpClient?:__HttpClientInterface;__};__export__interface__HttpClientInterface__{__makeApiRequest:__(request:__Request,__timeout:__number)__=>__Promise;__}__export__type__RetryConfig__=__{__/**__*__@enabled__whether__to__enable__retry__logic,__default__value__is__false__*__@maxRetries__maximum__number__of__retries,__default__value__is__3__*__@delayMs__delay__in__milliseconds__between__retries,__default__value__is__1000ms__*__@retryOn__array__of__HTTP__status__codes__to__retry__on,__default__value__is__[500,__502,__503,__504]__*/__enabled?:__boolean;__maxRetries?:__number;__delayMs?:__number;__retryOn?:__Array;__};__export__default__class__Chargebee__{__constructor(config:__Config);__customer:__Customer.CustomerResource;__}__}"); + "///__///__declare__module__'chargebee'__{__export__type__Config__=__{__/**__*__@apiKey__api__key__for__the__site.__*/__apiKey:__string;__/**__*__@site__api__site__name.__*/__site:__string;__/**__*__@apiPath__this__value__indicates__the__api__version,__default__value__is__/api/v2.__*/__apiPath?:__'/api/v2'__|__'/api/v1';__/**__*__@timeout__client__side__request__timeout__in__milliseconds,__default__value__is__80000ms.__*/__timeout?:__number;__/**__*__@port__url__port__*/__port?:__number;__/**__*__@timemachineWaitInMillis__time__interval__at__which__two__subsequent__retrieve__timemachine__call__in__milliseconds,__default__value__is__3000ms.__*/__timemachineWaitInMillis?:__number;__/**__*__@exportWaitInMillis__time__interval__at__which__two__subsequent__retrieve__export__call__in__milliseconds,__default__value__is__3000ms.__*/__exportWaitInMillis?:__number;__/**__*__@protocol__http__protocol,__default__value__is__https__*/__protocol?:__'https'__|__'http';__/**__*__@hostSuffix__url__host__suffix,__default__value__is__.chargebee.com__*/__hostSuffix?:__string;__/**__*__@retryConfig__retry__configuration__for__the__client,__default__value__is__{__enabled:__false,__maxRetries:__3,__delayMs:__1000,__retryOn:__[500,__502,__503,__504]}__*/__retryConfig?:__RetryConfig;__/**__*__@enableDebugLogs__whether__to__enable__debug__logs,__default__value__is__false__*/__enableDebugLogs?:__boolean;__/**__*__@userAgentSuffix__optional__string__appended__to__the__User-Agent__header__for__additional__logging__*/__userAgentSuffix?:__string;__/**__*__@httpClient__optional__http__client__implementation,__default__http__client__will__be__used__if__not__provided__*/__httpClient?:__HttpClientInterface;__};__export__interface__HttpClientInterface__{__makeApiRequest:__(request:__Request,__timeout:__number)__=>__Promise;__}__export__type__RetryConfig__=__{__/**__*__@enabled__whether__to__enable__retry__logic,__default__value__is__false__*__@maxRetries__maximum__number__of__retries,__default__value__is__3__*__@delayMs__delay__in__milliseconds__between__retries,__default__value__is__1000ms__*__@retryOn__array__of__HTTP__status__codes__to__retry__on,__default__value__is__[500,__502,__503,__504]__*/__enabled?:__boolean;__maxRetries?:__number;__delayMs?:__number;__retryOn?:__Array;__};__export__default__class__Chargebee__{__constructor(config:__Config);__customer:__Customer.CustomerResource;__/**__Webhook__handler__instance__with__auto-configured__Basic__Auth__(if__env__vars__are__set)__*/__webhooks:__WebhookHandler__&__{__/**__Create__a__new__typed__webhook__handler__instance__*/__createHandler(options?:__WebhookHandlerOptions):__WebhookHandler;__};__}__//__Webhook__Handler__Types__export__type__WebhookEventName__=__EventTypeEnum__|__'unhandled_event';__export__type__WebhookEventTypeValue__=__`${WebhookEventType}`;__/**__*__@deprecated__Renamed__to__`WebhookEventTypeValue`__for__clarity.__Use__`WebhookEventTypeValue`__instead.__*__This__alias__will__be__removed__in__the__next__major__version.__*/__export__type__WebhookContentTypeValue__=__WebhookEventTypeValue;__/**__*__Context__object__passed__to__webhook__event__listeners.__*__Wraps__the__event__data__with__optional__framework-specific__request/response__objects.__*/__export__interface__WebhookContext__{__/**__The__parsed__webhook__event__from__Chargebee__*/__event:__WebhookEvent;__/**__Framework-specific__request__object__(Express,__Fastify,__etc.)__*/__request?:__ReqT;__/**__Framework-specific__response__object__(Express,__Fastify,__etc.)__*/__response?:__ResT;__}__/**__*__Context__object__passed__to__webhook__error__listeners.__*__Contains__the__request/response__objects__so__errors__can__be__handled__appropriately.__*/__export__interface__WebhookErrorContext__{__/**__Framework-specific__request__object__(Express,__Fastify,__etc.)__*/__request?:__ReqT;__/**__Framework-specific__response__object__(Express,__Fastify,__etc.)__*/__response?:__ResT;__}__/**__*__Validator__function__type__for__authenticating__webhook__requests.__*__Can__be__synchronous__or__asynchronous.__*/__export__type__RequestValidator__=__(__headers:__Record,__)__=>__void__|__Promise;__/**__*__Configuration__options__for__WebhookHandler.__*/__export__interface__WebhookHandlerOptions__{__/**__*__Optional__validator__function__to__authenticate__incoming__webhook__requests.__*__Typically__used__for__Basic__Auth__validation.__*__Can__be__sync__or__async__-__throw__an__error__to__reject__the__request.__*/__requestValidator?:__RequestValidator;__}__/**__*__Options__for__the__handle()__method.__*/__export__interface__HandleOptions__{__/**__The__raw__request__body__(string)__or__pre-parsed__object__*/__body:__string__|__object;__/**__Optional__HTTP__headers__for__validation__*/__headers?:__Record;__/**__Optional__framework-specific__request__object__(Express,__Fastify,__etc.)__*/__request?:__ReqT;__/**__Optional__framework-specific__response__object__(Express,__Fastify,__etc.)__*/__response?:__ResT;__}__export__type__WebhookEventListener__=__(context:__WebhookContext__&__{__event:__WebhookEvent__})__=>__Promise__|__void;__export__type__WebhookErrorListener__=__(error:__Error,__context:__WebhookErrorContext)__=>__Promise__|__void;__//__Helper__type__to__map__string__literal__to__enum__member__type__StringToWebhookEventType__=__{__[K__in__WebhookEventType]:__`${K}`__extends__S__?__K__:__never__}[WebhookEventType];__export__interface__WebhookHandler__{__on(eventName:__T,__listener:__WebhookEventListener):__this;__on(eventName:__S,__listener:__WebhookEventListener>):__this;__on(eventName:__'unhandled_event',__listener:__WebhookEventListener):__this;__on(eventName:__'error',__listener:__WebhookErrorListener):__this;__once(eventName:__T,__listener:__WebhookEventListener):__this;__once(eventName:__S,__listener:__WebhookEventListener>):__this;__once(eventName:__'unhandled_event',__listener:__WebhookEventListener):__this;__once(eventName:__'error',__listener:__WebhookErrorListener):__this;__off(eventName:__T,__listener:__WebhookEventListener):__this;__off(eventName:__S,__listener:__WebhookEventListener>):__this;__off(eventName:__'unhandled_event',__listener:__WebhookEventListener):__this;__off(eventName:__'error',__listener:__WebhookErrorListener):__this;__handle(options:__HandleOptions):__Promise;__requestValidator:__RequestValidator__|__undefined;__}__//__Webhook__Auth__/**__*__Credential__validator__function__type.__*__Can__be__synchronous__or__asynchronous__(e.g.,__for__database__lookups).__*/__export__type__CredentialValidator__=__(__username:__string,__password:__string,__)__=>__boolean__|__Promise;__/**__*__Creates__a__Basic__Auth__validator__for__webhook__requests.__*/__export__function__basicAuthValidator(__validateCredentials:__CredentialValidator,__):__(headers:__Record)__=>__Promise;__//__Webhook__Error__Classes__/**__*__Base__class__for__all__webhook-related__errors.__*/__export__class__WebhookError__extends__Error__{__constructor(message:__string);__name:__string;__}__/**__*__Authentication__error__thrown__when__webhook__request__authentication__fails.__*__Typically__maps__to__HTTP__401__Unauthorized.__*/__export__class__WebhookAuthenticationError__extends__WebhookError__{__constructor(message:__string);__name:__string;__}__/**__*__Payload__validation__error__thrown__when__the__webhook__payload__structure__is__invalid.__*__Typically__maps__to__HTTP__400__Bad__Request.__*/__export__class__WebhookPayloadValidationError__extends__WebhookError__{__constructor(message:__string);__name:__string;__}__/**__*__JSON__parsing__error__thrown__when__the__webhook__body__cannot__be__parsed__as__JSON.__*__Typically__maps__to__HTTP__400__Bad__Request.__*/__export__class__WebhookPayloadParseError__extends__WebhookError__{__constructor(message:__string,__rawBody?:__string);__name:__string;__readonly__rawBody?:__string;__}__}"); } @Test