From 2abb6f86b348aa16d632e7bc1730729e49cc9811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Cord=C3=B3n?= Date: Tue, 30 Apr 2024 12:06:30 +0100 Subject: [PATCH] [pb3CtuNX] Adds convert.paths.toTree to deprecate old convert.toTree (#4044) --- core/src/main/java/apoc/convert/Json.java | 76 ++- .../apoc/convert/PathsToJsonTreeTest.java | 569 ++++++++++++++++++ docs/antora/server.js | 6 +- 3 files changed, 645 insertions(+), 6 deletions(-) create mode 100644 core/src/test/java/apoc/convert/PathsToJsonTreeTest.java diff --git a/core/src/main/java/apoc/convert/Json.java b/core/src/main/java/apoc/convert/Json.java index 84528e8f32..f3ce90249a 100644 --- a/core/src/main/java/apoc/convert/Json.java +++ b/core/src/main/java/apoc/convert/Json.java @@ -174,7 +174,7 @@ public List fromJsonList( return JsonUtil.parse(value, path, List.class, pathOptions); } - @Procedure("apoc.convert.toTree") + @Procedure(value = "apoc.convert.toTree", deprecatedBy = "apoc.paths.toJsonTree") @Description( "apoc.convert.toTree([paths],[lowerCaseRels=true], [config]) creates a stream of nested documents representing the at least one root of these paths") // todo optinally provide root node @@ -231,6 +231,73 @@ public Stream toTree( .map(MapResult::new); } + @Procedure("apoc.paths.toJsonTree") + @Description( + "apoc.paths.toJsonTree([paths],[lowerCaseRels=true], [config]) creates a stream of nested documents representing the graph as a tree by traversing outgoing relationships") + // todo optinally provide root node + public Stream pathsToTree( + @Name("paths") List paths, + @Name(value = "lowerCaseRels", defaultValue = "true") boolean lowerCaseRels, + @Name(value = "config", defaultValue = "{}") Map config) { + if (paths == null || paths.isEmpty()) return Stream.of(new MapResult(Collections.emptyMap())); + ConvertConfig conf = new ConvertConfig(config); + Map> nodes = conf.getNodes(); + Map> rels = conf.getRels(); + Set visitedInOtherPaths = new HashSet<>(); + Set nodesToKeepInResult = new HashSet<>(); + Map> tree = new HashMap<>(); + + Stream allPaths = paths.stream(); + if (conf.isSortPaths()) { + allPaths = allPaths.sorted(Comparator.comparingInt(Path::length).reversed()); + } + allPaths.forEach(path -> { + // This api will always return relationships in an outgoing fashion ()-[r]->() + var pathRelationships = path.relationships(); + pathRelationships.iterator().forEachRemaining((currentRel) -> { + Node currentNode = currentRel.getStartNode(); + Long currentNodeId = currentNode.getId(); + + if (!visitedInOtherPaths.contains(currentNodeId)) { + nodesToKeepInResult.add(currentNodeId); + } + + Node nextNode = currentRel.getEndNode(); + Map nodeMap = + tree.computeIfAbsent(currentNode.getId(), (id) -> toMap(currentNode, nodes)); + + Long nextNodeId = nextNode.getId(); + String typeName = lowerCaseRels + ? currentRel.getType().name().toLowerCase() + : currentRel.getType().name(); + // todo take direction into account and create collection into outgoing direction ?? + // parent-[:HAS_CHILD]->(child) vs. (parent)<-[:PARENT_OF]-(child) + if (!nodeMap.containsKey(typeName)) nodeMap.put(typeName, new ArrayList<>()); + // Check that this combination of rel and node doesn't already exist + List> currentNodeRels = (List) nodeMap.get(typeName); + boolean alreadyProcessedRel = currentNodeRels.stream() + .anyMatch(elem -> elem.get("_id").equals(nextNodeId) + && elem.get(typeName + "._id").equals(currentRel.getId())); + if (!alreadyProcessedRel) { + boolean nodeAlreadyVisited = tree.containsKey(nextNodeId); + Map nextNodeMap = toMap(nextNode, nodes); + addRelProperties(nextNodeMap, typeName, currentRel, rels); + + if (!nodeAlreadyVisited) { + tree.put(nextNodeId, nextNodeMap); + } + + visitedInOtherPaths.add(nextNodeId); + currentNodeRels.add(nextNodeMap); + } + }); + }); + + var result = + nodesToKeepInResult.stream().map(nodeId -> tree.get(nodeId)).map(MapResult::new); + return result; + } + @UserFunction("apoc.convert.toSortedJsonMap") @Description( "apoc.convert.toSortedJsonMap(node|map, ignoreCase:true) - returns a JSON map with keys sorted alphabetically, with optional case sensitivity") @@ -280,8 +347,11 @@ private Map toMap(Node n, Map> nodeFilters) String type = Util.labelString(n); result.put("_id", n.getId()); result.put("_type", type); - if (nodeFilters.containsKey(type)) { // Check if list contains LABEL - props = filterProperties(props, nodeFilters.get(type)); + var types = type.split(":"); + var filter = + Arrays.stream(types).filter((t) -> nodeFilters.containsKey(t)).findFirst(); + if (filter.isPresent()) { // Check if list contains LABEL + props = filterProperties(props, nodeFilters.get(filter.get())); } result.putAll(props); return result; diff --git a/core/src/test/java/apoc/convert/PathsToJsonTreeTest.java b/core/src/test/java/apoc/convert/PathsToJsonTreeTest.java new file mode 100644 index 0000000000..527a6e4f7f --- /dev/null +++ b/core/src/test/java/apoc/convert/PathsToJsonTreeTest.java @@ -0,0 +1,569 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package apoc.convert; + +import static org.junit.Assert.assertEquals; + +import apoc.util.JsonUtil; +import apoc.util.TestUtil; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.neo4j.graphdb.Result; +import org.neo4j.graphdb.Transaction; +import org.neo4j.test.rule.DbmsRule; +import org.neo4j.test.rule.ImpermanentDbmsRule; + +public class PathsToJsonTreeTest { + private Object parseJson(String json) { + return JsonUtil.parse(json, null, null); + } + + @Rule + public DbmsRule db = new ImpermanentDbmsRule(); + + @Before + public void setUp() throws Exception { + TestUtil.registerProcedure(db, Json.class); + } + + @After + public void teardown() { + db.shutdown(); + } + + @After + public void clear() { + db.executeTransactionally("MATCH (n) DETACH DELETE n;"); + } + + @Test + public void testToTreeSimplePath() throws Exception { + /* r:R + a:A --------> b:B + */ + db.executeTransactionally("CREATE (a: A {nodeName: 'a'})-[r: R {relName: 'r'}]->(b: B {nodeName: 'b'})"); + + var query = "MATCH path = (n)-[r]->(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + var expectedRow = "{" + " \"tree\":{" + + " \"nodeName\":\"a\"," + + " \"r\":[" + + " {" + + " \"nodeName\":\"b\"," + + " \"r._id\":0," + + " \"_type\":\"B\"," + + " \"_id\":1," + + " \"r.relName\":\"r\"" + + " }" + + " ]," + + " \"_type\":\"A\"," + + " \"_id\":0" + + " }" + + "}"; + assertEquals(rows.size(), 1); + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testToTreeSimpleReversePath() { + /* r:R + a:A <-------- b:B + */ + db.executeTransactionally("CREATE " + "(a: A {nodeName: 'a'})<-[r: R {relName: 'r'}]-(b: B {nodeName: 'b'})"); + + var query = "MATCH path = (n)<-[r]-(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + var expectedRow = "{" + " \"tree\":{" + + " \"nodeName\":\"b\"," + + " \"r\":[" + + " {" + + " \"nodeName\":\"a\"," + + " \"r._id\":0," + + " \"_type\":\"A\"," + + " \"_id\":0," + + " \"r.relName\":\"r\"" + + " }" + + " ]," + + " \"_type\":\"B\"," + + " \"_id\":1" + + " }" + + "}"; + assertEquals(rows.size(), 1); + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testToTreeSimpleBidirectionalPath() { + /* r1:R + --------> + a:A b:B + <-------- + r2:R + */ + db.executeTransactionally("CREATE " + + "(a: A {nodeName: 'a'})<-[r1: R {relName: 'r'}]-(b: B {nodeName: 'b'})," + + "(a)-[r2: R {relName: 'r'}]->(b)"); + + var query = "MATCH path = (n)-[r]-(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + var expectedRow = "{" + " \"tree\":{" + + " \"nodeName\":\"a\"," + + " \"r\":[" + + " {" + + " \"nodeName\":\"b\"," + + " \"r._id\":1," + + " \"r\":[" + + " {" + + " \"nodeName\":\"a\"," + + " \"r._id\":0," + + " \"_type\":\"A\"," + + " \"_id\":0," + + " \"r.relName\":\"r\"" + + " }" + + " ]," + + " \"_type\":\"B\"," + + " \"_id\":1," + + " \"r.relName\":\"r\"" + + " }" + + " ]," + + " \"_type\":\"A\"," + + " \"_id\":0" + + " }" + + "}"; + assertEquals(rows.size(), 1); + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testToTreeSimpleBidirectionalQuery() { + /* r1:R + a:A --------> b:B + */ + db.executeTransactionally("CREATE (a: A {nodeName: 'a'})-[r1: R {relName: 'r'}]->(b: B {nodeName: 'b'})"); + + // Note this would be returning both the path (a)-[r]->(b) and (b)<-[r]-(a) + // but we only expect a tree starting in 'a' + var query = "MATCH path = (n)-[r]-(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + var expectedRow = "{" + " \"tree\":{" + + " \"nodeName\":\"a\"," + + " \"r\":[" + + " {" + + " \"nodeName\":\"b\"," + + " \"r._id\":0," + + " \"_type\":\"B\"," + + " \"_id\":1," + + " \"r.relName\":\"r\"" + + " }" + + " ]," + + " \"_type\":\"A\"," + + " \"_id\":0" + + " }" + + "}"; + assertEquals(rows.size(), 1); + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testToTreeBidirectionalPathAndQuery() { + /* r1:R1 r2:R2 + a:A ---------> b:B --------> a + */ + db.executeTransactionally( + "CREATE (a: A {nodeName: 'a'})-[r1: R1 {relName: 'r1'}]->(b: B {nodeName: 'b'})-[r2: R2 {relName: 'r2'}]->(a)"); + + // The query is bidirectional in this case, so + // we would have duplicated paths, but we do not + // expect duplicated trees + var query = "MATCH path = (n)-[r]-(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + + assertEquals(rows.size(), 1); + var expectedRow = "{" + " \"tree\":{" + + " \"nodeName\":\"b\"," + + " \"_type\":\"B\"," + + " \"_id\":1," + + " \"r2\":[" + + " {" + + " \"nodeName\":\"a\"," + + " \"r1\":[" + + " {" + + " \"nodeName\":\"b\"," + + " \"r1._id\":0," + + " \"_type\":\"B\"," + + " \"r1.relName\":\"r1\"," + + " \"_id\":1" + + " }" + + " ]," + + " \"_type\":\"A\"," + + " \"r2._id\":1," + + " \"_id\":0," + + " \"r2.relName\":\"r2\"" + + " }" + + " ]" + + " }" + + "}"; + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testToTreeComplexGraph() { + /* r1:R1 r2:R2 + a:A --------> b:B ------> c:C + | + r3:R3 | + \|/ + d:D + */ + db.executeTransactionally("CREATE " + "(a: A {nodeName: 'a'})-[r1: R1 {relName: 'r1'}]->(b: B {nodeName: 'b'})," + + "(b)-[r2: R2 {relName: 'r2'}]->(c: C {nodeName: 'c'})," + + "(b)-[r3: R3 {relName: 'r3'}]->(d: D {nodeName: 'd'})"); + + var query = "MATCH path = (n)-[r]->(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + + assertEquals(rows.size(), 1); + var expectedRow = "{" + " \"tree\": {" + + " \"nodeName\": \"a\"," + + " \"_type\": \"A\"," + + " \"_id\": 0," + + " \"r1\": [" + + " {" + + " \"nodeName\": \"b\"," + + " \"r2\": [" + + " {" + + " \"nodeName\": \"c\"," + + " \"r2._id\": 1," + + " \"_type\": \"C\"," + + " \"r2.relName\": \"r2\"," + + " \"_id\": 2" + + " }" + + " ]," + + " \"r3\": [" + + " {" + + " \"nodeName\": \"d\"," + + " \"r3._id\": 2," + + " \"r3.relName\": \"r3\"," + + " \"_type\": \"D\"," + + " \"_id\": 3" + + " }" + + " ]," + + " \"_type\": \"B\"," + + " \"r1._id\": 0," + + " \"_id\": 1," + + " \"r1.relName\": \"r1\"" + + " }" + + " ]" + + " }" + + "}"; + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testToTreeComplexGraphBidirectionalQuery() { + /* r1:R1 r2:R2 + a:A --------> b:B -------> c:C + | + r3:R3 | + \|/ + d:D + */ + db.executeTransactionally("CREATE " + "(a: A {nodeName: 'a'})-[r1: R1 {relName: 'r1'}]->(b: B {nodeName: 'b'})," + + "(b)-[r2: R2 {relName: 'r2'}]->(c: C {nodeName: 'c'})," + + "(b)-[r3: R3 {relName: 'r3'}]->(d: D {nodeName: 'd'})"); + + // The query is bidirectional in this case, we don't expect duplicated paths + var query = "MATCH path = (n)-[r]-(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + + assertEquals(rows.size(), 1); + var expectedRow = "{" + " \"tree\": {" + + " \"nodeName\": \"a\"," + + " \"_type\": \"A\"," + + " \"_id\": 0," + + " \"r1\": [" + + " {" + + " \"nodeName\": \"b\"," + + " \"r2\": [" + + " {" + + " \"nodeName\": \"c\"," + + " \"r2._id\": 1," + + " \"_type\": \"C\"," + + " \"r2.relName\": \"r2\"," + + " \"_id\": 2" + + " }" + + " ]," + + " \"r3\": [" + + " {" + + " \"nodeName\": \"d\"," + + " \"r3._id\": 2," + + " \"r3.relName\": \"r3\"," + + " \"_type\": \"D\"," + + " \"_id\": 3" + + " }" + + " ]," + + " \"_type\": \"B\"," + + " \"r1._id\": 0," + + " \"_id\": 1," + + " \"r1.relName\": \"r1\"" + + " }" + + " ]" + + " }" + + "}"; + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testToTreeGraphWithLoops() { + /* r1:R1 r2:R2 + a:A ---------> b:B --------> c:C + / /|\ + |___| + r3:R3 + */ + db.executeTransactionally("CREATE " + "(a: A {nodeName: 'a'})-[r1: R1 {relName: 'r1'}]->(b: B {nodeName: 'b'})," + + "(b)-[r2: R2 {relName: 'r2'}]->(c:C {nodeName: 'c'})," + + "(b)-[r3: R3 {relName: 'r3'}]->(b)"); + + var query = "MATCH path = (n)-[r]->(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + + assertEquals(rows.size(), 1); + var expectedRow = "{" + " \"tree\":{" + + " \"nodeName\":\"a\"," + + " \"_type\":\"A\"," + + " \"_id\":0," + + " \"r1\":[" + + " {" + + " \"nodeName\":\"b\"," + + " \"r2\":[" + + " {" + + " \"nodeName\":\"c\"," + + " \"r2._id\":1," + + " \"_type\":\"C\"," + + " \"r2.relName\":\"r2\"," + + " \"_id\":2" + + " }" + + " ]," + + " \"r3\":[" + + " {" + + " \"nodeName\":\"b\"," + + " \"r3._id\":2," + + " \"r3.relName\":\"r3\"," + + " \"_type\":\"B\"," + + " \"_id\":1" + + " }" + + " ]," + + " \"_type\":\"B\"," + + " \"r1._id\":0," + + " \"_id\":1," + + " \"r1.relName\":\"r1\"" + + " }" + + " ]" + + " }" + + "}"; + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testIncomingRelationships() { + /* r1:R1 r2:R2 + a:A --------> b:B <------ c:C + */ + db.executeTransactionally( + "CREATE (a: A {nodeName: 'a'})-[r1: R1 {relName: 'r1'}]->(b: B {nodeName: 'b'})<-[r2: R2 {relName: 'r2'}]-(c:C {nodeName: 'c'})"); + + var query = "MATCH path = (n)-[:R1]->(m)<-[:R2]-(o)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {sortPaths: false}) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + + assertEquals(rows.size(), 2); + var expectedFirstRow = "{" + " \"tree\":{" + + " \"nodeName\":\"a\"," + + " \"_type\":\"A\"," + + " \"_id\":0," + + " \"r1\":[" + + " {" + + " \"nodeName\":\"b\"," + + " \"_type\":\"B\"," + + " \"r1._id\":0," + + " \"_id\":1," + + " \"r1.relName\":\"r1\"" + + " }" + + " ]" + + " }" + + "}"; + var expectedSecondRow = "{" + " \"tree\":{" + + " \"nodeName\":\"c\"," + + " \"r2\":[" + + " {" + + " \"nodeName\":\"b\"," + + " \"r2._id\":1," + + " \"_type\":\"B\"," + + " \"r2.relName\":\"r2\"," + + " \"_id\":1" + + " }" + + " ]," + + " \"_type\":\"C\"," + + " \"_id\":2" + + " }" + + "}"; + assertEquals(parseJson(expectedFirstRow), rows.get(0)); + assertEquals(parseJson(expectedSecondRow), rows.get(1)); + } + } + + @Test + public void testToTreeMultiLabelFilters() { + /* r:R + a:A:B -------> c:C + */ + db.executeTransactionally( + "CREATE " + "(a: A: B {nodeName: 'a & b'})-[r: R {relName: 'r'}]->(c: C {nodeName: 'c'})"); + + var query = "MATCH path = (n)-[r]->(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.paths.toJsonTree(paths, true, {nodes: { A: ['-nodeName'] } }) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + + assertEquals(rows.size(), 1); + // No nodename under A:B + var expectedRow = "{" + " \"tree\":{" + + " \"r\":[" + + " {" + + " \"nodeName\":\"c\"," + + " \"r._id\":0," + + " \"_type\":\"C\"," + + " \"_id\":1," + + " \"r.relName\":\"r\"" + + " }" + + " ]," + + " \"_type\":\"A:B\"," + + " \"_id\":0" + + " }" + + "}"; + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } + + @Test + public void testToTreeMultiLabelFiltersForOldProcedure() { + /* r:R + a:A:B -------> c:C + */ + db.executeTransactionally( + "CREATE " + "(a: A: B {nodeName: 'a & b'})-[r: R {relName: 'r'}]->(c: C {nodeName: 'c'})"); + + var query = "MATCH path = (n)-[r]->(m)\n" + + "WITH COLLECT(path) AS paths\n" + + "CALL apoc.convert.toTree(paths, true, {nodes: { A: ['-nodeName'] } }) YIELD value AS tree\n" + + "RETURN tree"; + + try (Transaction tx = db.beginTx()) { + Result result = tx.execute(query); + var rows = result.stream().collect(Collectors.toList()); + + assertEquals(rows.size(), 1); + // No nodename under A:B + var expectedRow = "{" + " \"tree\":{" + + " \"r\":[" + + " {" + + " \"nodeName\":\"c\"," + + " \"r._id\":0," + + " \"_type\":\"C\"," + + " \"_id\":1," + + " \"r.relName\":\"r\"" + + " }" + + " ]," + + " \"_type\":\"A:B\"," + + " \"_id\":0" + + " }" + + "}"; + assertEquals(parseJson(expectedRow), rows.get(0)); + } + } +} diff --git a/docs/antora/server.js b/docs/antora/server.js index 107937d33d..cdf2698dd0 100644 --- a/docs/antora/server.js +++ b/docs/antora/server.js @@ -1,10 +1,10 @@ const express = require('express') const app = express() -app.use(express.static('./build/site')) +app.use(express.static('./build/site/')) // app.use('/static/assets', express.static('./build/site/_')) -// app.get('/', (req, res) => res.redirect('/developer/')) +app.get('/', (req, res) => res.redirect('/apoc/4.4/')) -app.listen(8000, () => console.log('📘 http://localhost:8000')) \ No newline at end of file +app.listen(8000, () => console.log('📘 http://localhost:8000/')) \ No newline at end of file