Skip to content
Snippets Groups Projects
Commit 3f746155 authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Node listing implementation

parent d71c5542
No related branches found
No related tags found
No related merge requests found
Showing with 188 additions and 58 deletions
......@@ -14,6 +14,7 @@ import java.net.http.HttpResponse.BodyHandlers;
import java.util.Scanner;
import java.util.concurrent.CompletionException;
import java.util.function.Function;
import javax.xml.bind.JAXB;
import net.ivoa.xml.vospace.v2.Node;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
......@@ -25,6 +26,9 @@ public class VOSpaceClient {
private static final Logger LOG = LoggerFactory.getLogger(VOSpaceClient.class);
@Value("${use-json}")
private boolean useJson;
private static final ObjectMapper MAPPER = new ObjectMapper();
private final HttpClient httpClient;
......@@ -45,8 +49,8 @@ public class VOSpaceClient {
public Node getNode(String path) {
HttpRequest request = getRequest("/nodes/" + path)
.header("Accept", "application/json")
HttpRequest request = getRequest("/nodes" + path)
.header("Accept", useJson ? "application/json" : "text/xml")
.build();
return call(request, BodyHandlers.ofInputStream(), 200, res -> parseJson(res, Node.class));
......@@ -84,9 +88,13 @@ public class VOSpaceClient {
return HttpRequest.newBuilder(URI.create(baseUrl + path));
}
private static <T> T parseJson(InputStream in, Class<T> type) {
private <T> T parseJson(InputStream in, Class<T> type) {
try {
if (useJson) {
return MAPPER.readValue(in, type);
} else {
return JAXB.unmarshal(in, type);
}
} catch (IOException ex) {
LOG.error("Invalid JSON for class {}", type.getCanonicalName());
throw new UncheckedIOException(ex);
......
package it.inaf.ia2.vospace.ui.controller;
import it.inaf.ia2.vospace.ui.service.NodesService;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
......@@ -18,16 +18,22 @@ public class NodesController {
* This is the only API endpoint that returns HTML code instead of JSON. The
* reason is that JavaScript frameworks are not very efficient in handling
* very long lists and tables, so this part of the code is generated
* server-side.
* server-side. The content type is set to text/plain even if it is an HTML
* fragment to avoid browser parsing issues since it is not a complete HTML
* document.
*/
@GetMapping(value = {"/nodes", "/nodes/{path}"}, produces = MediaType.TEXT_HTML_VALUE)
public void listNodes(@PathVariable(value = "path", required = false) String path, HttpServletResponse response) throws Exception {
@GetMapping(value = {"/nodes", "/nodes/**"}, produces = MediaType.TEXT_PLAIN_VALUE)
public String listNodes(HttpServletRequest request) throws Exception {
if (path == null || path.isBlank()) {
path = "/";
String requestURL = request.getRequestURL().toString();
String[] split = requestURL.split("/nodes/");
String path = "/";
if (split.length == 2) {
path += split[1];
}
nodesService.generateNodesHtml(path, response.getOutputStream());
return nodesService.generateNodesHtml(path);
}
@GetMapping(value = "/download/{path}")
......
package it.inaf.ia2.vospace.ui.service;
import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.IOException;
import java.io.StringWriter;
import java.io.UncheckedIOException;
import net.ivoa.xml.vospace.v2.ContainerNode;
import net.ivoa.xml.vospace.v2.Node;
import org.slf4j.Logger;
......@@ -22,20 +23,24 @@ public class NodesService {
@Value("${vospace-authority}")
private String authority;
public void generateNodesHtml(String path, OutputStream out) {
public String generateNodesHtml(String path) {
Node node = client.getNode(path);
try ( PrintWriter pw = new PrintWriter(out)) {
try ( StringWriter sw = new StringWriter()) {
if (node instanceof ContainerNode) {
ContainerNode folder = (ContainerNode) node;
pw.println("<tbody id=\"nodes\">");
sw.write("<tbody id=\"nodes\">");
for (Node child : folder.getNodes().getNode()) {
pw.println(getNodeHtml(child));
sw.write(getNodeHtml(child));
}
pw.println("</tbody>");
sw.write("</tbody>");
}
return sw.toString();
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
......@@ -67,7 +72,7 @@ public class NodesService {
private String getLink(NodeInfo nodeInfo) {
if (isDownloadable(nodeInfo)) {
if ("vos:ContainerNode".equals(nodeInfo.getType())) {
return "<a href=\"#nodes" + nodeInfo.getPath() + "\">" + nodeInfo.getName() + "</a>";
return "<a href=\"#/nodes" + nodeInfo.getPath() + "\">" + nodeInfo.getName() + "</a>";
} else {
return "<a href=\"download" + nodeInfo.getPath() + "\" target=\"blank_\">" + nodeInfo.getName() + "</a>";
}
......
......@@ -2,6 +2,7 @@ server.port=8085
vospace-backend-url=http://localhost:8083/vospace
vospace-authority=example.com!vospace
use-json=true
# For development only:
spring.profiles.active=dev
......
package it.inaf.ia2.vospace.ui.controller;
import it.inaf.ia2.vospace.ui.service.NodesService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.mockito.ArgumentMatchers.eq;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import static org.mockito.Mockito.verify;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@ExtendWith(MockitoExtension.class)
public class NodesControllerTest {
@Mock
private NodesService nodesService;
@InjectMocks
private NodesController controller;
private MockMvc mockMvc;
@BeforeEach
public void init() {
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
public void testListNodesEmpty() throws Exception {
mockMvc.perform(get("/nodes"))
.andExpect(status().isOk());
verify(nodesService).generateNodesHtml(eq("/"));
}
@Test
public void testListNodesRoot() throws Exception {
mockMvc.perform(get("/nodes/"))
.andExpect(status().isOk());
verify(nodesService).generateNodesHtml(eq("/"));
}
@Test
public void testListNodesComplexPath() throws Exception {
mockMvc.perform(get("/nodes/a/b/c"))
.andExpect(status().isOk());
verify(nodesService).generateNodesHtml(eq("/a/b/c"));
}
}
......@@ -53,7 +53,7 @@ function loading(value) {
export default {
getNode(path) {
let url = BASE_API_URL + 'nodes' + path;
let url = BASE_API_URL + 'nodes/' + path;
return apiRequest({
method: 'GET',
url: url,
......
<template>
<div class="container">
<b-breadcrumb :items="breadcrumbs"></b-breadcrumb>
<b-card>
<table class="table b-table table-striped table-hover">
<thead>
<tr>
<th id="checkboxes">
<a href="#" @click.stop.prevent="selectAll"><BIconCheckSquare /></a>
<a href="#" @click.stop.prevent="selectAll">
<BIconCheckSquare />
</a>
&nbsp;
<a href="#" @click.stop.prevent="deSelectAll"><BIconSquare /></a>
<a href="#" @click.stop.prevent="deSelectAll">
<BIconSquare />
</a>
</th>
<th>Name</th>
<th>Size</th>
......@@ -22,20 +27,41 @@
</template>
<script>
import client from 'api-client';
import { BIconCheckSquare, BIconSquare } from 'bootstrap-vue'
export default {
components: {
BIconCheckSquare, BIconSquare
BIconCheckSquare,
BIconSquare
},
mounted() {
client.getNode(this.$store.state.path)
.then(res => {
document.getElementById('nodes').outerHTML = res;
computed: {
breadcrumbs() {
let items = [{
text: 'ROOT',
href: '#/nodes/'
}];
if (this.$store.state.path) {
let pathSplit = this.$store.state.path.split('/');
for (let i = 0; i < pathSplit.length; i++) {
items.push({
text: pathSplit[i],
href: '#/nodes/' + pathSplit.slice(0, i + 1).join('/')
});
}
}
return items;
}
},
created() {
this.loadNode();
},
watch: {
'$route.params.path': 'loadNode'
},
methods: {
loadNode() {
this.$store.dispatch('setPath', this.$route.params.path);
},
selectAll() {
this.selectInputs(true);
},
......@@ -56,6 +82,10 @@ export default {
text-align: left;
}
#nodes>tr>td:nth-child(2) {
white-space: nowrap;
}
th#checkboxes {
/* Credits: https://stackoverflow.com/a/43615091/771431 */
width: 0.1%;
......
......@@ -5,6 +5,10 @@ import Main from './components/Main.vue';
export default new VueRouter({
routes: [{
path: '/',
redirect: '/nodes/'
}, {
name: 'nodes',
path: '/nodes/:path(.*)',
component: Main
}]
})
......@@ -2,18 +2,35 @@
import Vue from 'vue';
import Vuex from 'vuex';
import client from 'api-client';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
path: '/',
//breadcrumbs: [],
path: '',
loading: true
},
mutations: {
setLoading(state, loading) {
state.loading = loading;
},
setPath(state, value) {
if (!value) {
value = '';
}
state.path = value;
//state.breadcrumbs = value.split('/')
}
},
actions: {}
actions: {
setPath({ state, commit }, path) {
commit('setPath', path);
client.getNode(state.path)
.then(res => {
document.getElementById('nodes').outerHTML = res
});
}
}
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment