diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/SecurityConfig.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/SecurityConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..fa123439669c224cfb20866febc9f11a355ffc6e --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/SecurityConfig.java @@ -0,0 +1,41 @@ +package it.inaf.ia2.vospace.ui; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class SecurityConfig { + + private static final Logger LOG = LoggerFactory.getLogger(SecurityConfig.class); + + @Value("${cors.allowed.origin}") + private String corsAllowedOrigin; + + /** + * CORS are necessary only for development (API access from npm server). + */ + @Bean + @Profile("dev") + public WebMvcConfigurer corsConfigurer() { + + return new WebMvcConfigurer() { + + @Override + public void addCorsMappings(CorsRegistry registry) { + + LOG.warn("Development profile active: CORS filter enabled"); + + registry.addMapping("/**") + .allowedOrigins(corsAllowedOrigin) + .allowedMethods("*") + .allowCredentials(true); + } + }; + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceException.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceException.java new file mode 100644 index 0000000000000000000000000000000000000000..2b227f5cd5b27b4bd956a6bb2690a0b5e7aebb80 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceException.java @@ -0,0 +1,12 @@ +package it.inaf.ia2.vospace.ui; + +public class VOSpaceException extends RuntimeException { + + public VOSpaceException(String message) { + super(message); + } + + public VOSpaceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VospaceUiApplication.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java similarity index 69% rename from vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VospaceUiApplication.java rename to vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java index ad9aac9ef8cee7664dca87a71b666c9a4fef9788..010de2fa4abd09bf7dc2a68c10b09a4c19e36ce0 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VospaceUiApplication.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java @@ -4,9 +4,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class VospaceUiApplication { +public class VOSpaceUiApplication { public static void main(String[] args) { - SpringApplication.run(VospaceUiApplication.class, args); + SpringApplication.run(VOSpaceUiApplication.class, args); } } diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java new file mode 100644 index 0000000000000000000000000000000000000000..9a0aebbd4c08b9e0e84b5038353ced4ed10c5341 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java @@ -0,0 +1,121 @@ +package it.inaf.ia2.vospace.ui.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import it.inaf.ia2.vospace.ui.VOSpaceException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.ConnectException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Scanner; +import java.util.concurrent.CompletionException; +import java.util.function.Function; +import net.ivoa.xml.vospace.v2.Node; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class VOSpaceClient { + + private static final Logger LOG = LoggerFactory.getLogger(VOSpaceClient.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpClient httpClient; + private final String baseUrl; + + public VOSpaceClient(@Value("${vospace-backend-url}") String backendUrl) { + if (backendUrl.endsWith("/")) { + // Remove final slash from configured URL + backendUrl = backendUrl.substring(0, backendUrl.length() - 1); + } + baseUrl = backendUrl; + + httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + public Node getNode(String path) { + + HttpRequest request = getRequest("/nodes/" + path) + .header("Accept", "application/json") + .build(); + + return call(request, BodyHandlers.ofInputStream(), 200, res -> parseJson(res, Node.class)); + } + + private <T, U> U call(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, int expectedStatusCode, Function<T, U> responseHandler) { + try { + return httpClient.sendAsync(request, responseBodyHandler) + .thenApply(response -> { + if (response.statusCode() == expectedStatusCode) { + return response.body(); + } + logServerError(request, response); + throw new VOSpaceException("Error calling " + request.uri().toString() + ". Server response code is " + response.statusCode()); + }) + .thenApply(response -> responseHandler.apply(response)) + .join(); + } catch (CompletionException ex) { + if (ex.getCause() != null) { + if (ex.getCause() instanceof ConnectException) { + throw new VOSpaceException("Cannot connect to " + request.uri().getHost() + " on port " + request.uri().getPort()); + } + if (ex.getCause() instanceof VOSpaceException) { + throw (VOSpaceException) ex.getCause(); + } + LOG.error("Error calling " + request.uri().toString(), ex.getCause()); + throw new VOSpaceException("Error calling " + request.uri().toString(), ex.getCause()); + } + LOG.error("Error calling " + request.uri().toString(), ex); + throw new VOSpaceException("Error calling " + request.uri().toString(), ex); + } + } + + private HttpRequest.Builder getRequest(String path) { + return HttpRequest.newBuilder(URI.create(baseUrl + path)); + } + + private static <T> T parseJson(InputStream in, Class<T> type) { + try { + return MAPPER.readValue(in, type); + } catch (IOException ex) { + LOG.error("Invalid JSON for class {}", type.getCanonicalName()); + throw new UncheckedIOException(ex); + } + } + + private static <T> void logServerError(HttpRequest request, HttpResponse<T> response) { + if (response.body() instanceof String) { + logServerErrorString(request, (HttpResponse<String>) response); + } else if (response.body() instanceof InputStream) { + logServerErrorInputStream(request, (HttpResponse<InputStream>) response); + } else { + throw new UnsupportedOperationException("Unable to log error for response body type " + response.body().getClass().getCanonicalName()); + } + } + + private static void logServerErrorString(HttpRequest request, HttpResponse<String> response) { + + LOG.error("Error while reading " + request.uri() + + "\nServer response status code is " + response.statusCode() + + "\nServer response text is " + response.body()); + } + + private static void logServerErrorInputStream(HttpRequest request, HttpResponse<InputStream> response) { + Scanner s = new Scanner(response.body()).useDelimiter("\\A"); + String responseBody = s.hasNext() ? s.next() : ""; + String error = "Error while reading " + request.uri() + + "\nServer response status code is " + response.statusCode() + + "\nServer response text is " + responseBody; + LOG.error(error); + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/IndexController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/IndexController.java new file mode 100644 index 0000000000000000000000000000000000000000..a0c826534f17bc15c319f5a186941fb852cc3754 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/IndexController.java @@ -0,0 +1,16 @@ +package it.inaf.ia2.vospace.ui.controller; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class IndexController { + + @GetMapping(value = "/", produces = MediaType.TEXT_HTML_VALUE) + public String index(HttpServletRequest request, HttpServletResponse response) { + return "index.html"; + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java new file mode 100644 index 0000000000000000000000000000000000000000..9f406d101c53266166ea6321a8040989ac99d672 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java @@ -0,0 +1,32 @@ +package it.inaf.ia2.vospace.ui.controller; + +import it.inaf.ia2.vospace.ui.service.NodesService; +import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class NodesController { + + @Autowired + private NodesService nodesService; + + /** + * 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. + */ + @GetMapping(value = {"/nodes", "/nodes/{path}"}, produces = MediaType.TEXT_HTML_VALUE) + public void listNodes(@PathVariable(value = "path", required = false) String path, HttpServletResponse response) throws Exception { + + if (path == null || path.isBlank()) { + path = "/"; + } + + nodesService.generateNodesHtml(path, response.getOutputStream()); + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java new file mode 100644 index 0000000000000000000000000000000000000000..96c21b822d6f80ea45380cdbc5c960242891d178 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java @@ -0,0 +1,60 @@ +package it.inaf.ia2.vospace.ui.service; + +import it.inaf.ia2.vospace.ui.VOSpaceException; +import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import java.io.OutputStream; +import java.io.PrintWriter; +import net.ivoa.xml.vospace.v2.ContainerNode; +import net.ivoa.xml.vospace.v2.Node; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class NodesService { + + @Autowired + private VOSpaceClient client; + + @Value("${vospace-authority}") + private String authority; + + public void generateNodesHtml(String path, OutputStream out) { + + Node node = client.getNode(path); + + try ( PrintWriter pw = new PrintWriter(out)) { + + if (node instanceof ContainerNode) { + ContainerNode folder = (ContainerNode) node; + pw.println("<tbody id=\"nodes\">"); + for (Node child : folder.getNodes().getNode()) { + pw.println(getNodeHtml(child)); + } + pw.println("</tbody>"); + } + } + } + + private String getNodeHtml(Node node) { + String html = "<tr>"; + html += "<td><a href=\"#\">"; + html += getName(node); + html += "</a></td>"; + html += "</tr>"; + return html; + } + + private String getName(Node node) { + + String uri = node.getUri(); + + String prefix = "vos://" + authority; + + if (!uri.startsWith(prefix)) { + throw new VOSpaceException("Node authority is different from configured one! Configured is " + authority + ", but node URI is " + uri); + } + + return uri.substring(prefix.length() + 1); + } +} diff --git a/vospace-ui-backend/src/main/resources/application.properties b/vospace-ui-backend/src/main/resources/application.properties index 8b137891791fe96927ad78e64b0aad7bded08bdc..934e28401708dbfcd3b590f1385ba466ca827601 100644 --- a/vospace-ui-backend/src/main/resources/application.properties +++ b/vospace-ui-backend/src/main/resources/application.properties @@ -1 +1,8 @@ +server.port=8085 +vospace-backend-url=http://localhost:8083/vospace +vospace-authority=example.com!vospace + +# For development only: +spring.profiles.active=dev +cors.allowed.origin=http://localhost:8080 diff --git a/vospace-ui-frontend/.env.production b/vospace-ui-frontend/.env.production new file mode 100644 index 0000000000000000000000000000000000000000..6010f55022316c82ff11bfeeb216720afd9614ea --- /dev/null +++ b/vospace-ui-frontend/.env.production @@ -0,0 +1,2 @@ +VUE_APP_API_CLIENT = 'server' +VUE_APP_API_BASE_URL = '' diff --git a/vospace-ui-frontend/package-lock.json b/vospace-ui-frontend/package-lock.json index 178aac529ca10dd4405c86f182502433c624fef5..80ecd14f2baab7fa25a6b2b9535978bf8550446d 100644 --- a/vospace-ui-frontend/package-lock.json +++ b/vospace-ui-frontend/package-lock.json @@ -2538,6 +2538,14 @@ "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", "dev": true }, + "axios": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz", + "integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "babel-eslint": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", @@ -5523,8 +5531,7 @@ "follow-redirects": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==", - "dev": true + "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" }, "for-in": { "version": "1.0.2", diff --git a/vospace-ui-frontend/package.json b/vospace-ui-frontend/package.json index 1864acb38c91a2f3bf0a99de79b07bffb8a113da..c452bb8b40c5640b7f051e973b68f50f1bb4a4cf 100644 --- a/vospace-ui-frontend/package.json +++ b/vospace-ui-frontend/package.json @@ -8,6 +8,7 @@ "lint": "vue-cli-service lint" }, "dependencies": { + "axios": "^0.21.0", "bootstrap-vue": "^2.19.0", "core-js": "^3.6.5", "vue": "^2.6.11", diff --git a/vospace-ui-frontend/src/App.vue b/vospace-ui-frontend/src/App.vue index 2363d6b7c2837555c5f10609f66f69f5c18a4e0b..938c672de1302520b952ab38f2680ae70823b3fb 100644 --- a/vospace-ui-frontend/src/App.vue +++ b/vospace-ui-frontend/src/App.vue @@ -1,9 +1,48 @@ <template> <div id="app"> + <TopMenu /> + <div class="container"> + <router-view></router-view> + </div> + <div id="footer-fix"></div> + <footer class="text-center" id="site-footer"> + — Powered by <img alt="IA2 logo" src="./assets/ia2-logo-footer.png"> + <strong class="text-primary"><a href="http://www.ia2.inaf.it/" target="blank_">IA2</a></strong> — + </footer> + <div id="loading" v-if="loading"> + <div class="spinner-wrapper"> + <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner> + </div> + </div> </div> </template> <script> +import { mapState } from 'vuex' +import TopMenu from './components/TopMenu.vue' + +export default { + name: 'App', + computed: mapState({ + loading: state => state.loading + }), + components: { + TopMenu + }, + mounted: function() { + var self = this; + document.addEventListener('apiError', function(event) { + self.$bvToast.toast(event.message.body, { + title: event.message.title, + variant: 'danger', + solid: true + }); + }); + document.addEventListener('loading', function(event) { + self.$store.commit('setLoading', event.value); + }); + } +} </script> <style> @@ -13,6 +52,40 @@ -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; - margin-top: 60px; +} + +#footer-fix { + display: block; + width: 100%; + height: 100px; +} + +#site-footer { + color: #666; + border-top: 1px #e7e7e7 solid; + background-color: #f8f8f8; + padding: 5px 0; + position: fixed; + bottom: 0; + right: 0; + left: 0; +} + +#loading { + position: fixed; + top: 0; + bottom: 0; + right: 0; + left: 0; + background-color: rgba(255, 255, 255, 0.7); + z-index: 1000; +} + +.spinner-wrapper { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; } </style> diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js new file mode 100644 index 0000000000000000000000000000000000000000..31c8eb7d8192fed385abf5bf863531b24dfbfa0a --- /dev/null +++ b/vospace-ui-frontend/src/api/server/index.js @@ -0,0 +1,66 @@ +const BASE_API_URL = process.env.VUE_APP_API_BASE_URL; + +import axios from 'axios'; + +function apiRequest(options, showLoading = true, handleValidationErrors = false) { + if (showLoading) { + loading(true); + } + return new Promise((resolve, reject) => { + axios(options) + .then(response => { + if (response.status === 204) { + resolve({}); + } else { + resolve(response.data); + } + loading(false); + }) + .catch(error => { + loading(false); + if (handleValidationErrors && error.response && error.response.status === 400) { + reject(error.response.data); + } else { + dispatchApiErrorEvent(error); + } + }); + }); +} + +function dispatchApiErrorEvent(error) { + let event = new CustomEvent('apiError'); + let errorMessage; + if (error.response && error.response.data && error.response.data.message) { + errorMessage = error.response.data.message; + } else if (error.message) { + errorMessage = error.message; + } else { + errorMessage = 'Unknown error'; + } + event.message = { + title: error.error || 'Error', + body: errorMessage + }; + document.dispatchEvent(event); +} + +/* For loading animation */ +function loading(value) { + let event = new CustomEvent('loading'); + event.value = value; + document.dispatchEvent(event); +} + +export default { + getNode(path) { + let url = BASE_API_URL + 'nodes' + path; + return apiRequest({ + method: 'GET', + url: url, + withCredentials: true, + headers: { + 'Cache-Control': 'no-cache' + } + }, true, true); + } +} diff --git a/vospace-ui-frontend/src/assets/ia2-logo-footer.png b/vospace-ui-frontend/src/assets/ia2-logo-footer.png new file mode 100644 index 0000000000000000000000000000000000000000..cb913c9d1bfb1815de802febddfdec6dd2b56552 Binary files /dev/null and b/vospace-ui-frontend/src/assets/ia2-logo-footer.png differ diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue index 7b8b46cb048401ef9ed9fcfed81ea73eb7b0bd35..482dffd8d8f3c1736916ac0a52d594e139646aa3 100644 --- a/vospace-ui-frontend/src/components/Main.vue +++ b/vospace-ui-frontend/src/components/Main.vue @@ -1,3 +1,27 @@ <template> - <div></div> +<div class="container"> + <b-card> + <table class="table b-table table-striped table-hover"> + <thead> + <tr> + <th>File</th> + </tr> + </thead> + <tbody id="nodes"></tbody> + </table> + </b-card> +</div> </template> + +<script> +import client from 'api-client'; + +export default { + mounted() { + client.getNode(this.$store.state.path) + .then(res => { + document.getElementById('nodes').outerHTML = res; + }); + } +} +</script> diff --git a/vospace-ui-frontend/src/components/TopMenu.vue b/vospace-ui-frontend/src/components/TopMenu.vue new file mode 100644 index 0000000000000000000000000000000000000000..1115343a9e7ffbd5b1defe3993f5c3dcbd617efc --- /dev/null +++ b/vospace-ui-frontend/src/components/TopMenu.vue @@ -0,0 +1,14 @@ +<template> +<div> + <b-navbar toggleable="lg" type="dark" id="top-menu"> + <b-navbar-brand href="#" class="d-none d-md-block">VOSpace</b-navbar-brand> + </b-navbar> +</div> +</template> + +<style> +#top-menu { + background-color: #093784; + margin-bottom: 20px; +} +</style> diff --git a/vospace-ui-frontend/src/main.js b/vospace-ui-frontend/src/main.js index 8761db38cfb514741f54f9da615cb33b615beff3..9324cf322260b752aa4ad03f43c26bb27a4823f8 100644 --- a/vospace-ui-frontend/src/main.js +++ b/vospace-ui-frontend/src/main.js @@ -1,6 +1,7 @@ import Vue from 'vue' import App from './App.vue' import store from './store.js' +import './plugins/bootstrap-vue' import { BootstrapVue } from 'bootstrap-vue' diff --git a/vospace-ui-frontend/src/plugins/bootstrap-vue.js b/vospace-ui-frontend/src/plugins/bootstrap-vue.js new file mode 100644 index 0000000000000000000000000000000000000000..b9d414fabeae0480e997423c3a02ef5414b5a0b3 --- /dev/null +++ b/vospace-ui-frontend/src/plugins/bootstrap-vue.js @@ -0,0 +1,7 @@ +import Vue from 'vue'; + +import { BootstrapVue } from 'bootstrap-vue'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'bootstrap-vue/dist/bootstrap-vue.css'; + +Vue.use(BootstrapVue); diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index 4eadaf171bc563471dcabc07bb995fe65f162c76..464672b52388d1583acf79eb5fc412e832355318 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -7,9 +7,13 @@ Vue.use(Vuex); export default new Vuex.Store({ state: { + path: '/', + loading: true }, mutations: { + setLoading(state, loading) { + state.loading = loading; + } }, - actions: { - } + actions: {} }); diff --git a/vospace-ui-frontend/vue.config.js b/vospace-ui-frontend/vue.config.js new file mode 100644 index 0000000000000000000000000000000000000000..93ef4cbf7ba4ae11f7d8cde4b5cfd575e48eeae1 --- /dev/null +++ b/vospace-ui-frontend/vue.config.js @@ -0,0 +1,12 @@ +const path = require('path'); + +module.exports = { + publicPath: '', + chainWebpack: config => { + const apiClient = process.env.VUE_APP_API_CLIENT // mock or server + config.resolve.alias.set( + 'api-client', + path.resolve(__dirname, `src/api/${apiClient}`) + ) + } +}