RBAC Diagram+PostgreSQL Generator #21

Merged
hsh-michaelhoennig merged 54 commits from experimental-rbacview-generator into master 2024-03-11 12:30:44 +01:00
13 changed files with 208 additions and 71 deletions
Showing only changes of commit 17282c857f - Show all commits

View File

@ -12,6 +12,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.IOException;
import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.*;
@ -72,4 +73,8 @@ public class HsOfficeBankAccountEntity implements HasUuid, Stringifyable {
with.permission(VIEW);
});
}
public static void main(String[] args) throws IOException {
rbac().generateWithBaseFileName("243-hs-office-bankaccount-rbac");
}
}

View File

@ -11,6 +11,7 @@ import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.hibernate.annotations.GenericGenerator;
import jakarta.persistence.*;
import java.io.IOException;
import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL;
@ -76,4 +77,8 @@ public class HsOfficeContactEntity implements Stringifyable, HasUuid {
with.permission(VIEW);
});
}
public static void main(String[] args) throws IOException {
rbac().generateWithBaseFileName("203-hs-office-contact-rbac");
}
}

View File

@ -14,6 +14,7 @@ import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.hibernate.annotations.GenericGenerator;
import jakarta.persistence.*;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
@ -123,7 +124,7 @@ public class HsOfficeDebitorEntity implements HasUuid, Stringifyable {
.withUpdatableColumns(
"debitorRel",
"billable",
"billingContactUuid",
"debitorUuid",
"refundBankAccountUuid",
"vatId",
"vatCountryCode",
@ -144,7 +145,7 @@ public class HsOfficeDebitorEntity implements HasUuid, Stringifyable {
.createPermission(VIEW).grantedTo("debitorRel", TENANT)
.importEntityAlias("refundBankAccount", HsOfficeBankAccountEntity.class,
dependsOnColumn("bankAccountUuid"), fetchedBySql("""
dependsOnColumn("refundBankAccountUuid"), fetchedBySql("""
SELECT *
FROM hs_office_relationship AS r
WHERE r.relType = 'ACCOUNTING' AND r.relHolderUuid = ${REF}.debitorRelUuid
@ -154,7 +155,7 @@ public class HsOfficeDebitorEntity implements HasUuid, Stringifyable {
.toRole("debitorRel", AGENT).grantRole("refundBankAccount", REFERRER)
.importEntityAlias("partnerRel", HsOfficeRelationshipEntity.class,
dependsOnColumn("debitorRelUuid"), fetchedBySql("""
dependsOnColumn("partnerRelUuid"), fetchedBySql("""
SELECT *
FROM hs_office_relationship AS partnerRel
WHERE ${debitorRel}.relAnchorUuid = partnerRel.relHolderUuid
@ -168,4 +169,8 @@ public class HsOfficeDebitorEntity implements HasUuid, Stringifyable {
.forExampleRole("operationalPerson", ADMIN).wouldBeGrantedTo("partnerRel", ADMIN)
.forExampleRole("partnerRel", TENANT).wouldBeGrantedTo("partnerPerson", REFERRER);
}
public static void main(String[] args) throws IOException {
rbac().generateWithBaseFileName("273-hs-office-debitor-rbac");
}
}

View File

@ -10,6 +10,7 @@ import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
import jakarta.persistence.*;
import java.io.IOException;
import java.time.LocalDate;
import java.util.UUID;
@ -100,4 +101,8 @@ public class HsOfficePartnerDetailsEntity implements HasUuid, Stringifyable {
// not when anything in partner details changes.
;
}
public static void main(String[] args) throws IOException {
rbac().generateWithBaseFileName("234-hs-office-partner-details-rbac");
}
}

View File

@ -108,6 +108,6 @@ public class HsOfficePartnerEntity implements Stringifyable, HasUuid {
}
public static void main(String[] args) throws IOException {
HsOfficePartnerEntity.rbac().generateWithBaseFileName("233-hs-office-partner-rbac");
rbac().generateWithBaseFileName("233-hs-office-partner-rbac");
}
}

View File

@ -10,6 +10,7 @@ import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.apache.commons.lang3.StringUtils;
import jakarta.persistence.*;
import java.io.IOException;
import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL;
@ -80,4 +81,9 @@ public class HsOfficePersonEntity implements HasUuid, Stringifyable {
with.permission(VIEW);
});
}
public static void main(String[] args) throws IOException {
rbac().generateWithBaseFileName("213-hs-office-person-rbac");
}
}

View File

@ -11,6 +11,7 @@ import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
import jakarta.persistence.*;
import java.io.IOException;
import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn;
@ -86,13 +87,16 @@ public class HsOfficeRelationshipEntity implements HasUuid, Stringifyable {
"""))
.withUpdatableColumns("contactUuid")
.importEntityAlias("anchorPerson", HsOfficePersonEntity.class,
dependsOnColumn("relAnchorUuid"), fetchedBySql("select * from hs_office_person as p where p.uuid = ${REF}.relAnchorUuid")
dependsOnColumn("relAnchorUuid"),
fetchedBySql("select * from hs_office_person as p where p.uuid = ${REF}.relAnchorUuid")
)
.importEntityAlias("holderPerson", HsOfficePersonEntity.class,
dependsOnColumn("relHolderUuid"), fetchedBySql("select * from hs_office_person as p where p.uuid = ${REF}.relHolderUuid")
dependsOnColumn("relHolderUuid"),
fetchedBySql("select * from hs_office_person as p where p.uuid = ${REF}.relHolderUuid")
)
.importEntityAlias("contact", HsOfficeContactEntity.class,
dependsOnColumn("contactUuid"), fetchedBySql("select * from hs_office_contact as c where c.uuid = ${REF}.contactUuid")
dependsOnColumn("contactUuid"),
fetchedBySql("select * from hs_office_contact as c where c.uuid = ${REF}.contactUuid")
)
.createRole(OWNER, (with) -> {
with.owningUser(CREATOR);
@ -115,4 +119,8 @@ public class HsOfficeRelationshipEntity implements HasUuid, Stringifyable {
with.permission(VIEW);
});
}
public static void main(String[] args) throws IOException {
rbac().generateWithBaseFileName("223-hs-office-relationship-rbac");
}
}

View File

@ -123,6 +123,6 @@ public class HsOfficeSepaMandateEntity implements Stringifyable, HasUuid {
}
public static void main(String[] args) throws IOException {
HsOfficeSepaMandateEntity.rbac().generateWithBaseFileName("253-hs-office-sepamandate-rbac");
rbac().generateWithBaseFileName("253-hs-office-sepamandate-rbac");
}
}

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.rbac.rbacdef;
import java.lang.reflect.Method;
import java.nio.file.Path;
import java.util.function.Consumer;
@ -12,7 +13,9 @@ import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
import java.util.stream.Stream;
import static java.lang.reflect.Modifier.isStatic;
import static java.util.Optional.ofNullable;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.autoFetched;
@ -57,7 +60,6 @@ public class RbacView {
new RbacUserReference(CREATOR);
entityAliases.put("global", new EntityAlias("global"));
}
public RbacView withUpdatableColumns(final String... columnNames) {
Collections.addAll(updatableColumns, columnNames);
return this;
@ -493,10 +495,11 @@ public class RbacView {
return entityClass == null;
}
@NotNull
@Override
public SQL fetchSql() {
if ( fetchSql == null ) {
return null;
return SQL.noop();
}
return switch (fetchSql.part) {
case SQL_QUERY -> fetchSql;
@ -505,6 +508,10 @@ public class RbacView {
};
}
public boolean hasFetchSql() {
return fetchSql != null;
}
private String withoutEntitySuffix(final String simpleEntityName) {
return simpleEntityName.substring(0, simpleEntityName.length()-"Entity".length());
}
@ -583,6 +590,15 @@ public class RbacView {
return new SQL(null, Part.AUTO_FETCH);
}
/**
* DSL method to specify there there is no SQL query specified.
*
* @return a wrapped SQL definition object representing a noop query
*/
public static SQL noop() {
return new SQL(null, Part.NOOP);
}
/** Generic DSL method to specify an SQL SELECT expression.
*
* @param sql an SQL SELECT expression (not ending with ';)
@ -604,8 +620,10 @@ public class RbacView {
}
enum Part {
NOOP,
SQL_QUERY,
AUTO_FETCH, SQL_PROJECTION
AUTO_FETCH,
SQL_PROJECTION
}
final String sql;
@ -668,4 +686,35 @@ public class RbacView {
return outerAliasName + "." + originalAliasName;
}
}
public static void main(String[] args) {
Stream.of(
net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity.class,
net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity.class,
net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerDetailsEntity.class,
net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity.class,
net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity.class,
net.hostsharing.hsadminng.hs.office.relationship.HsOfficeRelationshipEntity.class,
net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionEntity.class,
net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity.class,
net.hostsharing.hsadminng.hs.office.sepamandate.HsOfficeSepaMandateEntity.class,
net.hostsharing.hsadminng.hs.office.coopshares.HsOfficeCoopSharesTransactionEntity.class,
net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity.class
).forEach(c -> {
final Method mainMethod = Arrays.stream(c.getMethods()).filter(
m -> isStatic(m.getModifiers()) && m.getName().equals("main")
)
.findFirst()
.orElse(null);
if (mainMethod != null) {
try {
mainMethod.invoke(null, new Object[]{null});
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
} else {
System.err.println("no main method in: " + c.getName());
}
});
}
}

View File

@ -76,7 +76,7 @@ public class RbacViewMermaidFlowchart {
private void wrapOutputInSubgraph(final String name, final String color, final String content) {
if (!StringUtils.isEmpty(content)) {
flowchart.ensureEmptyLine();
flowchart.ensureSingleEmptyLine();
flowchart.writeLn("subgraph " + name + "[ ]\n");
flowchart.indented(() -> {
flowchart.writeLn("style %{aliasName} fill:%{fillColor},stroke:white"
@ -102,7 +102,7 @@ public class RbacViewMermaidFlowchart {
.filter(g -> g.grantType() == f)
.toList();
if ( !userGrants.isEmpty()) {
flowchart.ensureEmptyLine();
flowchart.ensureSingleEmptyLine();
flowchart.writeLn(t);
userGrants.forEach(g -> flowchart.writeLn(grantDef(g)));
}

View File

@ -8,6 +8,8 @@ import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import static net.hostsharing.hsadminng.rbac.rbacdef.PostgresTriggerReference.NEW;
import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with;
public class RbacViewPostgresGenerator {
@ -21,10 +23,11 @@ public class RbacViewPostgresGenerator {
liqibaseTagPrefix = rbacDef.getRootEntityAlias().entityClass().getSimpleName();
plPgSql.writeLn("""
--liquibase formatted sql
-- This code generated was by ${generator} at %{timestamp}.
"""
.replace("${generator}", getClass().getSimpleName())
.replace("%{timestamp}", LocalDateTime.now().toString()));
-- This code generated was by ${generator} at ${timestamp}.
""",
with("generator", getClass().getSimpleName()),
with("timestamp", LocalDateTime.now().toString()),
with("ref", NEW.name()));
new RolesGrantsAndPermissionsGenerator(rbacDef, liqibaseTagPrefix).generateTo(plPgSql);
}

View File

@ -4,7 +4,6 @@ import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacPermissionDefinition;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
@ -15,6 +14,7 @@ import static net.hostsharing.hsadminng.rbac.rbacdef.PostgresTriggerReference.OL
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacGrantDefinition.GrantType.*;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.getRawTableName;
import static net.hostsharing.hsadminng.rbac.rbacdef.StringWriter.with;
import static org.apache.commons.lang3.StringUtils.capitalize;
import static org.apache.commons.lang3.StringUtils.uncapitalize;
@ -72,20 +72,25 @@ class RolesGrantsAndPermissionsGenerator {
NEW ${rawTableName}
)
language plpgsql as $$
declare
"""
.replace("${simpleEntityName}", simpleEntityName)
.replace("${rawTableName}", rawTableName));
plPgSql.chopEmptyLines();
plPgSql.indented(() -> {
referencedEntityAliases()
.forEach((ea) -> plPgSql.writeLn(entityRefVar(NEW, ea) + " " + getRawTableName(ea.entityClass()) + ";"));
updatableEntityAliases()
.forEach((ea) -> {
plPgSql.writeLn(entityRefVar(NEW, ea) + " " + getRawTableName(ea.entityClass()) + ";");
});
.forEach((ea) -> plPgSql.writeLn(entityRefVar(OLD, ea) + " " + getRawTableName(ea.entityClass()) + ";"));
});
plPgSql.writeLn();
plPgSql.writeLn("begin");
plPgSql.indented(() -> {
plPgSql.writeLn("begin");
generateCreateRolesAndGrantsAfterInsert(plPgSql);
if (hasAnyUpdatableEntityAliases()) {
@ -96,28 +101,28 @@ class RolesGrantsAndPermissionsGenerator {
raise exception 'invalid usage of TRIGGER';
end if;
""");
plPgSql.ensureEmptyLine();
plPgSql.ensureSingleEmptyLine();
});
plPgSql.writeLn("end; $$;");
plPgSql.writeLn();
}
private boolean hasAnyUpdatableEntityAliases() {
return updatableEntityAliases().anyMatch(e -> true);
return updatableEntityAliases().anyMatch(e -> true);
}
private void generateCreateRolesAndGrantsAfterInsert(final StringWriter plPgSql) {
plPgSql.ensureEmptyLine();
referencedEntityAliases()
.forEach((ea) -> plPgSql.writeLn(
ea.fetchSql().sql + " into " + entityRefVar(NEW, ea) + ";",
with("ref", NEW.name())));
plPgSql.ensureSingleEmptyLine();
plPgSql.writeLn("if TG_OP = 'INSERT' then");
plPgSql.indented(() -> {
updatableEntityAliases()
.forEach((ea) -> {
plPgSql.writeLn(
ea.fetchSql().sql.replace("${ref}", NEW.name()) + " into " + entityRefVar(NEW, ea) + ";");
});
plPgSql.chopEmptyLines();
createRolesWithGrantsSql(plPgSql, OWNER);
createRolesWithGrantsSql(plPgSql, ADMIN);
createRolesWithGrantsSql(plPgSql, AGENT);
@ -127,38 +132,36 @@ class RolesGrantsAndPermissionsGenerator {
generateGrants(plPgSql, ROLE_TO_USER);
generateGrants(plPgSql, ROLE_TO_ROLE);
generateGrants(plPgSql, PERM_TO_ROLE);
plPgSql.ensureSingleEmptyLine();
});
}
private Stream<RbacView.EntityAlias> referencedEntityAliases() {
return rbacDef.getEntityAliases().values().stream()
.filter((ea) -> !rbacDef.isRootEntityAlias(ea))
.filter((ea) -> ea.fetchSql() != null);
.filter(ea -> !rbacDef.isRootEntityAlias(ea))
.filter(ea -> ea.dependsOnColum() != null)
.filter(ea -> ea.entityClass() != null)
.filter(ea -> ea.fetchSql() != null);
}
private Stream<RbacView.EntityAlias> updatableEntityAliases() {
return referencedEntityAliases()
.filter(ea -> rbacDef.getUpdatableColumns().contains(ea.dependsOnColum().column) );
.filter(ea -> rbacDef.getUpdatableColumns().contains(ea.dependsOnColum().column));
}
private void generateUpdateRolesAndGrantsAfterUpdate(final StringWriter plPgSql) {
plPgSql.ensureEmptyLine();
plPgSql.ensureSingleEmptyLine();
plPgSql.writeLn("elsif TG_OP = 'UPDATE' then");
plPgSql.indented(() -> {
rbacDef.getEntityAliases().values().stream()
.filter(ea -> !rbacDef.isRootEntityAlias(ea))
.filter(ea -> ea.fetchSql() != null)
.forEach(ea -> {
plPgSql.writeLn(
ea.fetchSql().sql.replace("${ref}", OLD.name()) + " into " + entityRefVar(OLD, ea) + ";");
});
updatableEntityAliases()
.forEach((ea) -> plPgSql.writeLn(
ea.fetchSql().sql + " into " + entityRefVar(OLD, ea) + ";",
with("ref", OLD.name())));
rbacDef.getEntityAliases().values().stream()
updatableEntityAliases()
.map(RbacView.EntityAlias::dependsOnColum)
.filter(Objects::nonNull)
.filter(this::isUpdatable)
.map(c -> c.column)
.sorted()
.distinct()
@ -182,34 +185,48 @@ class RolesGrantsAndPermissionsGenerator {
.filter(RbacView.RbacGrantDefinition::isToCreate)
.filter(g -> g.dependsOnColumn(columnName))
.forEach(g -> {
plPgSql.writeLn("-- TODO: revoke " + g);
plPgSql.ensureSingleEmptyLine();
plPgSql.writeLn(generateRevoke(g));
plPgSql.writeLn(generateGrant(g));
plPgSql.writeLn();
});
}
private void generateGrants(final StringWriter plPgSql, final RbacView.RbacGrantDefinition.GrantType grantType) {
plPgSql.ensureEmptyLine();
plPgSql.ensureSingleEmptyLine();
rbacGrants.stream()
.filter(g -> g.grantType() == grantType)
.map(this::generateGrant)
.sorted()
.forEach(plPgSql::writeLn);
.forEach(text -> plPgSql.writeLn(text));
}
private String generateRevoke(RbacView.RbacGrantDefinition grantDef) {
return switch (grantDef.grantType()) {
case ROLE_TO_USER -> throw new IllegalArgumentException("unexpected grant");
case ROLE_TO_ROLE -> "call revokeRoleFromRole(${subRoleRef}, ${superRoleRef});"
.replace("${subRoleRef}", roleRef(OLD, grantDef.getSubRoleDef()))
.replace("${superRoleRef}", roleRef(OLD, grantDef.getSuperRoleDef()));
case PERM_TO_ROLE -> "call revokePermissionFromRole(${permRef}, ${superRoleRef});"
.replace("${permRef}", permRef(OLD, grantDef.getPermDef()))
.replace("${superRoleRef}", roleRef(OLD, grantDef.getSuperRoleDef()));
};
}
private String generateGrant(RbacView.RbacGrantDefinition grantDef) {
return switch (grantDef.grantType()) {
case ROLE_TO_USER -> throw new IllegalArgumentException("unexpected grant");
case ROLE_TO_ROLE -> "call grantRoleToRole(${subRoleRef}, ${superRoleRef}));"
case ROLE_TO_ROLE -> "call grantRoleToRole(${subRoleRef}, ${superRoleRef});"
.replace("${subRoleRef}", roleRef(NEW, grantDef.getSubRoleDef()))
.replace("${superRoleRef}", roleRef(NEW, grantDef.getSuperRoleDef()));
case PERM_TO_ROLE -> "call grantPermissionsToRole(${permRef}, ${superRoleRef}));"
case PERM_TO_ROLE -> "call grantPermissionsToRole(${permRef}, ${superRoleRef});"
.replace("${permRef}", permRef(NEW, grantDef.getPermDef()))
.replace("${superRoleRef}", roleRef(NEW, grantDef.getSuperRoleDef()));
};
}
private String permRef(final PostgresTriggerReference ref, final RbacPermissionDefinition permDef) {
return "createPermissions(${entityRef}.uuid, array ['${perm}']"
return "createPermissions(${entityRef}.uuid, array ['${perm}'])"
.replace("${entityRef}", rbacDef.isRootEntityAlias(permDef.entityAlias)
? ref.name()
: refVarName(ref, permDef.entityAlias))
@ -232,10 +249,12 @@ class RolesGrantsAndPermissionsGenerator {
+ "(" + entityRefVar + ")";
}
private static String entityRefVar(
private String entityRefVar(
final PostgresTriggerReference rootRefVar,
final RbacView.EntityAlias entityAlias) {
return rootRefVar.name().toLowerCase() + capitalize(entityAlias.aliasName());
return rbacDef.isRootEntityAlias(entityAlias)
? rootRefVar.name()
: rootRefVar.name().toLowerCase() + capitalize(entityAlias.aliasName());
}
private void createRolesWithGrantsSql(final StringWriter plPgSql, final RbacView.Role role) {
@ -369,7 +388,7 @@ class RolesGrantsAndPermissionsGenerator {
private void generateInsertTrigger(final StringWriter plPgSql) {
plPgSql.writeLn("""
/*
An AFTER INSERT TRIGGER which creates the role structure for a new ${simpleEntityName}
AFTER INSERT TRIGGER to create the role+grant structure for a new ${rawTableName} row.
*/
create or replace function insertTriggerFor${simpleEntityName}_tf()
@ -396,7 +415,7 @@ class RolesGrantsAndPermissionsGenerator {
private void generateUpdateTrigger(final StringWriter plPgSql) {
plPgSql.writeLn("""
/*
An AFTER UPDATE TRIGGER which re-wires the grant structure for an updated ${simpleEntityName}
AFTER INSERT TRIGGER to re-wire the grant structure for a new ${rawTableName} row.
*/
create or replace function updateTriggerFor${simpleEntityName}_tf()

View File

@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.rbac.rbacdef;
import org.apache.commons.lang3.StringUtils;
import java.util.regex.Pattern;
import static java.util.Arrays.stream;
import static java.util.stream.Collectors.joining;
@ -10,24 +12,22 @@ public class StringWriter {
private final StringBuilder string = new StringBuilder();
private int indentLevel = 0;
static VarDef with(final String var, final String name) {
return new VarDef(var, name);
}
void writeLn(final String text) {
string.append( indented(text));
writeLn();
}
void writeLn() {
string.append( "\n");
void writeLn(final String text, final VarDef... varDefs) {
string.append( indented( new VarReplacer(varDefs).apply(text) ));
writeLn();
}
private String indented(final String text) {
if ( indentLevel == 0) {
return text;
}
final var indentation = StringUtils.repeat(" ", indentLevel);
final var indented = stream(text.split("\n"))
.map(line -> line.trim().isBlank() ? "" : indentation + line)
.collect(joining("\n"));
return indented;
void writeLn() {
string.append( "\n");
}
void indent() {
@ -58,14 +58,46 @@ public class StringWriter {
};
}
void ensureEmptyLine() {
if (!string.toString().endsWith("\n\n")) {
writeLn();
}
void ensureSingleEmptyLine() {
chopEmptyLines();
writeLn();
}
@Override
public String toString() {
return string.toString();
}
private String indented(final String text) {
if ( indentLevel == 0) {
return text;
}
final var indentation = StringUtils.repeat(" ", indentLevel);
final var indented = stream(text.split("\n"))
.map(line -> line.trim().isBlank() ? "" : indentation + line)
.collect(joining("\n"));
return indented;
}
record VarDef(String name, String value){}
private static final class VarReplacer {
private final VarDef[] varDefs;
private String text;
private VarReplacer(VarDef[] varDefs) {
this.varDefs = varDefs;
}
String apply(final String text) {
this.text = text;
stream(varDefs).forEach(varDef -> {
final var pattern = Pattern.compile("\\$\\{" + varDef.name() + "}", Pattern.CASE_INSENSITIVE);
final var matcher = pattern.matcher(text);
this.text = matcher.replaceAll(varDef.value());
});
return this.text;
}
}
}