From 73ee942873e70ff39b3126e08a0f65af37baa2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Sp=C3=B6nemann?= Date: Tue, 29 Sep 2020 13:06:51 +0000 Subject: [PATCH] #163: More consistent status codes, added 'query' endpoint --- README.md | 1 + cli/src/registry.ts | 7 +- server/.gitignore | 1 + .../eclipse/openvsx/ExtensionProcessor.java | 3 +- .../eclipse/openvsx/IExtensionRegistry.java | 4 + .../eclipse/openvsx/LocalRegistryService.java | 101 +++++++++- .../java/org/eclipse/openvsx/RegistryAPI.java | 172 +++++++++++++----- .../openvsx/UpstreamRegistryService.java | 13 ++ .../java/org/eclipse/openvsx/UserAPI.java | 32 ++-- .../eclipse/openvsx/json/QueryParamJson.java | 46 +++++ .../eclipse/openvsx/json/QueryResultJson.java | 36 ++++ .../repositories/ExtensionRepository.java | 2 + .../repositories/NamespaceRepository.java | 4 +- .../repositories/RepositoryService.java | 8 + .../openvsx/security/SecurityConfig.java | 7 +- .../openvsx/util/ErrorResultException.java | 19 +- .../org/eclipse/openvsx/RegistryAPITest.java | 142 +++++++++++++-- .../java/org/eclipse/openvsx/UserAPITest.java | 8 +- webui/package.json | 1 + 19 files changed, 517 insertions(+), 90 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/json/QueryParamJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/QueryResultJson.java diff --git a/README.md b/README.md index b8ae8e891..ed1c58ddb 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The command line tool is available at `cli/lib/ovsx`. * `./gradlew build` — build and test the server * `./gradlew assemble -t` — build continuously (the server is restarted after every change) * `./gradlew runServer` — start the Spring server on port 8080 + * `./scripts/test-report.sh` — display test results on port 8081 The Spring server is started automatically in Gitpod. It includes `spring-boot-devtools` which detects changes in the compiled class files and restarts the server. diff --git a/cli/src/registry.ts b/cli/src/registry.ts index 05db39852..d66b54bbc 100644 --- a/cli/src/registry.ts +++ b/cli/src/registry.ts @@ -159,9 +159,10 @@ export class Registry { if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) { if (json.startsWith('{')) { try { - const error = JSON.parse(json) as ErrorResponse; - if (error.message) { - reject(new Error(error.message)); + const parsed = JSON.parse(json) as ErrorResponse; + const message = parsed.message || parsed.error; + if (message) { + reject(new Error(message)); return; } } catch (err) { diff --git a/server/.gitignore b/server/.gitignore index 4d642cefc..52c0d84b9 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -5,4 +5,5 @@ .classpath .project DEPENDENCIES +/src/dev/resources/static/ /src/dev/resources/application-ovsx.properties diff --git a/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java b/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java index 41c9b90da..e6aefe336 100644 --- a/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java +++ b/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java @@ -34,6 +34,7 @@ import org.eclipse.openvsx.util.LicenseDetection; import org.elasticsearch.common.Strings; import org.springframework.data.util.Pair; +import org.springframework.http.HttpStatus; /** * Processes uploaded extension files and extracts their metadata. @@ -82,7 +83,7 @@ private void readInputStream() { try { content = ByteStreams.toByteArray(inputStream); if (content.length > MAX_CONTENT_SIZE) - throw new ErrorResultException("The extension package exceeds the size limit of 512 MB."); + throw new ErrorResultException("The extension package exceeds the size limit of 512 MB.", HttpStatus.PAYLOAD_TOO_LARGE); var tempFile = File.createTempFile("extension_", ".vsix"); Files.write(content, tempFile); zipFile = new ZipFile(tempFile); diff --git a/server/src/main/java/org/eclipse/openvsx/IExtensionRegistry.java b/server/src/main/java/org/eclipse/openvsx/IExtensionRegistry.java index 2fa241021..08fabf78b 100644 --- a/server/src/main/java/org/eclipse/openvsx/IExtensionRegistry.java +++ b/server/src/main/java/org/eclipse/openvsx/IExtensionRegistry.java @@ -11,6 +11,8 @@ import org.eclipse.openvsx.json.ExtensionJson; import org.eclipse.openvsx.json.NamespaceJson; +import org.eclipse.openvsx.json.QueryParamJson; +import org.eclipse.openvsx.json.QueryResultJson; import org.eclipse.openvsx.json.ReviewListJson; import org.eclipse.openvsx.json.SearchResultJson; import org.eclipse.openvsx.search.SearchService; @@ -33,4 +35,6 @@ public interface IExtensionRegistry { SearchResultJson search(SearchService.Options options); + QueryResultJson query(QueryParamJson param); + } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index 858c0f7db..4c44edd83 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -22,6 +22,7 @@ import javax.transaction.Transactional; import com.google.common.base.Joiner; +import com.google.common.base.Strings; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; @@ -36,6 +37,8 @@ import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.json.ExtensionJson; import org.eclipse.openvsx.json.NamespaceJson; +import org.eclipse.openvsx.json.QueryParamJson; +import org.eclipse.openvsx.json.QueryResultJson; import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.json.ReviewJson; import org.eclipse.openvsx.json.ReviewListJson; @@ -210,6 +213,100 @@ private List toSearchEntries(Page page, int si return CollectionUtil.map(page.getContent(), es -> toSearchEntry(es, serverUrl, options)); } + @Override + public QueryResultJson query(QueryParamJson param) { + if (!Strings.isNullOrEmpty(param.extensionId)) { + var split = param.extensionId.split("\\."); + if (split.length != 2 || split[0].isEmpty() || split[1].isEmpty()) + throw new ErrorResultException("The 'extensionId' parameter must have the format 'namespace.extension'."); + if (!Strings.isNullOrEmpty(param.namespaceName) && !param.namespaceName.equals(split[0])) + throw new ErrorResultException("Conflicting parameters 'extensionId' and 'namespaceName'"); + if (!Strings.isNullOrEmpty(param.extensionName) && !param.extensionName.equals(split[1])) + throw new ErrorResultException("Conflicting parameters 'extensionId' and 'extensionName'"); + param.namespaceName = split[0]; + param.extensionName = split[1]; + } + var result = new QueryResultJson(); + result.extensions = new ArrayList<>(); + // Add extension by UUID (public_id) + if (!Strings.isNullOrEmpty(param.extensionUuid)) { + var extension = repositories.findExtensionByPublicId(param.extensionUuid); + addToResult(extension, result, param); + } + // Add extensions by namespace UUID (public_id) + if (!Strings.isNullOrEmpty(param.namespaceUuid)) { + var namespace = repositories.findNamespaceByPublicId(param.namespaceUuid); + addToResult(namespace, result, param); + } + // Add a specific version of an extension + if (!Strings.isNullOrEmpty(param.namespaceName) && !Strings.isNullOrEmpty(param.extensionName) + && !Strings.isNullOrEmpty(param.extensionVersion) && !param.includeAllVersions) { + var extVersion = repositories.findVersion(param.extensionVersion, param.extensionName, param.namespaceName); + addToResult(extVersion, result, param); + // Add extension by namespace and name + } else if (!Strings.isNullOrEmpty(param.namespaceName) && !Strings.isNullOrEmpty(param.extensionName)) { + var extension = repositories.findExtension(param.extensionName, param.namespaceName); + addToResult(extension, result, param); + // Add extensions by namespace + } else if (!Strings.isNullOrEmpty(param.namespaceName)) { + var namespace = repositories.findNamespace(param.namespaceName); + addToResult(namespace, result, param); + // Add extensions by name + } else if (!Strings.isNullOrEmpty(param.extensionName)) { + var extensions = repositories.findExtensions(param.extensionName); + for (var extension : extensions) { + addToResult(extension, result, param); + } + } + return result; + } + + private void addToResult(Namespace namespace, QueryResultJson result, QueryParamJson param) { + if (namespace == null) + return; + for (var extension : repositories.findExtensions(namespace)) { + addToResult(extension, result, param); + } + } + + private void addToResult(Extension extension, QueryResultJson result, QueryParamJson param) { + if (extension == null) + return; + if (param.includeAllVersions) { + var allVersions = Lists.newArrayList(repositories.findVersions(extension)); + Collections.sort(allVersions, ExtensionVersion.SORT_COMPARATOR); + for (var extVersion : allVersions) { + addToResult(extVersion, result, param); + } + } else { + addToResult(extension.getLatest(), result, param); + } + } + + private void addToResult(ExtensionVersion extVersion, QueryResultJson result, QueryParamJson param) { + if (extVersion == null) + return; + if (mismatch(extVersion.getVersion(), param.extensionVersion)) + return; + var extension = extVersion.getExtension(); + if (mismatch(extension.getName(), param.extensionName)) + return; + var namespace = extension.getNamespace(); + if (mismatch(namespace.getName(), param.namespaceName)) + return; + if (mismatch(extension.getPublicId(), param.extensionUuid) || mismatch(namespace.getPublicId(), param.namespaceUuid)) + return; + if (result.extensions == null) + result.extensions = new ArrayList<>(); + result.extensions.add(toJson(extVersion)); + } + + private static boolean mismatch(String s1, String s2) { + return s1 != null && s2 != null + && !s1.isEmpty() && !s2.isEmpty() + && !s1.equalsIgnoreCase(s2); + } + @Transactional(rollbackOn = ErrorResultException.class) public ResultJson createNamespace(NamespaceJson json, String tokenValue) { var namespaceIssue = validator.validateNamespace(json.name); @@ -343,7 +440,7 @@ private void storeResources(List resources, ExtensionVersion extVe private void addDependency(String dependency, ExtensionVersion extVersion) { var split = dependency.split("\\."); - if (split.length != 2) { + if (split.length != 2 || split[0].isEmpty() || split[1].isEmpty()) { throw new ErrorResultException("Invalid 'extensionDependencies' format. Expected: '${namespace}.${name}'"); } var extension = repositories.findExtension(split[1], split[0]); @@ -360,7 +457,7 @@ private void addDependency(String dependency, ExtensionVersion extVersion) { private void addBundledExtension(String bundled, ExtensionVersion extVersion) { var split = bundled.split("\\."); - if (split.length != 2) { + if (split.length != 2 || split[0].isEmpty() || split[1].isEmpty()) { throw new ErrorResultException("Invalid 'extensionPack' format. Expected: '${namespace}.${name}'"); } var extension = repositories.findExtension(split[1], split[0]); diff --git a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java index ab7a9c873..cafb75f8e 100644 --- a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java @@ -11,7 +11,6 @@ import java.io.InputStream; import java.net.URI; -import java.net.URISyntaxException; import java.util.ArrayList; import java.util.List; @@ -19,6 +18,8 @@ import org.eclipse.openvsx.json.ExtensionJson; import org.eclipse.openvsx.json.NamespaceJson; +import org.eclipse.openvsx.json.QueryParamJson; +import org.eclipse.openvsx.json.QueryResultJson; import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.json.ReviewJson; import org.eclipse.openvsx.json.ReviewListJson; @@ -30,7 +31,6 @@ import org.eclipse.openvsx.util.UrlUtil; import org.elasticsearch.common.Strings; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -94,6 +94,7 @@ public NamespaceJson getNamespace(@PathVariable @ApiParam(value = "Namespace nam // Try the next registry } } + // TODO return 404 status return NamespaceJson.error("Namespace not found: " + namespace); } @@ -120,6 +121,7 @@ public ExtensionJson getExtension(@PathVariable @ApiParam(value = "Extension nam // Try the next registry } } + // TODO return 404 status return ExtensionJson.error("Extension not found: " + namespace + "." + extension); } @@ -148,6 +150,7 @@ public ExtensionJson getExtension(@PathVariable @ApiParam(value = "Extension nam // Try the next registry } } + // TODO return 404 status return ExtensionJson.error("Extension not found: " + namespace + "." + extension + " version " + version); } @@ -200,21 +203,27 @@ public ResponseEntity getFile(@PathVariable @ApiParam(value = "Extension @ApiResponses({ @ApiResponse( code = 200, - message = "The 'error' property indicates whether the request failed" + message = "The reviews are returned in JSON format" + ), + @ApiResponse( + code = 404, + message = "The specified extension could not be found" ) }) - public ReviewListJson getReviews(@PathVariable @ApiParam(value = "Extension namespace", example = "redhat") - String namespace, - @PathVariable @ApiParam(value = "Extension name", example = "java") - String extension) { + public ResponseEntity getReviews(@PathVariable @ApiParam(value = "Extension namespace", example = "redhat") + String namespace, + @PathVariable @ApiParam(value = "Extension name", example = "java") + String extension) { for (var registry : getRegistries()) { try { - return registry.getReviews(namespace, extension); + var json = registry.getReviews(namespace, extension); + return ResponseEntity.ok(json); } catch (NotFoundException exc) { // Try the next registry } } - return ReviewListJson.error("Extension not found: " + namespace + "." + extension); + var json = ReviewListJson.error("Extension not found: " + namespace + "." + extension); + return new ResponseEntity<>(json, HttpStatus.NOT_FOUND); } @GetMapping( @@ -222,16 +231,20 @@ public ReviewListJson getReviews(@PathVariable @ApiParam(value = "Extension name produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin - @ApiOperation("Search extensions via a query string") + @ApiOperation("Search extensions via text entered by a user") @ApiResponses({ @ApiResponse( code = 200, - message = "The 'error' property indicates whether the request failed" + message = "The search results are returned in JSON format" + ), + @ApiResponse( + code = 400, + message = "The request contains an invalid parameter value" ) }) - public SearchResultJson search( + public ResponseEntity search( @RequestParam(required = false) - @ApiParam(value = "Query string for searching", example = "javascript") + @ApiParam(value = "Query text for searching", example = "javascript") String query, @RequestParam(required = false) @ApiParam(value = "Extension category as shown in the UI", example = "Programming Languages") @@ -253,10 +266,12 @@ public SearchResultJson search( boolean includeAllVersions ) { if (size < 0) { - return SearchResultJson.error("The parameter 'size' must not be negative."); + var json = SearchResultJson.error("The parameter 'size' must not be negative."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } if (offset < 0) { - return SearchResultJson.error("The parameter 'offset' must not be negative."); + var json = SearchResultJson.error("The parameter 'offset' must not be negative."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } var options = new SearchService.Options(query, category, size, offset, sortOrder, sortBy, includeAllVersions); @@ -264,7 +279,7 @@ public SearchResultJson search( result.extensions = new ArrayList<>(size); for (var registry : getRegistries()) { if (result.extensions.size() >= size) { - return result; + return ResponseEntity.ok(result); } try { var subResult = registry.search(options); @@ -278,10 +293,12 @@ public SearchResultJson search( } catch (NotFoundException exc) { // Try the next registry } catch (ErrorResultException exc) { - return SearchResultJson.error(exc.getMessage()); + var json = SearchResultJson.error(exc.getMessage()); + var status = exc.getStatus() != null ? exc.getStatus() : HttpStatus.BAD_REQUEST; + return new ResponseEntity<>(json, status); } } - return result; + return ResponseEntity.ok(result); } private int mergeSearchResults(SearchResultJson result, List entries, int limit) { @@ -298,6 +315,46 @@ private int mergeSearchResults(SearchResultJson result, List en return mergedEntries; } + @PostMapping( + path = "/api/-/query", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @ApiOperation("Provides metadata of extensions matching the given parameters") + @ApiResponses({ + @ApiResponse( + code = 200, + message = "Returns the (possibly empty) query results" + ), + @ApiResponse( + code = 400, + message = "The request contains an invalid parameter value" + ) + }) + public ResponseEntity query(@RequestBody @ApiParam("Parameters of the metadata query") + QueryParamJson param) { + var result = new QueryResultJson(); + for (var registry : getRegistries()) { + try { + var subResult = registry.query(param); + if (subResult.extensions != null) { + if (result.extensions == null) + result.extensions = subResult.extensions; + else + result.extensions.addAll(subResult.extensions); + } + } catch (NotFoundException exc) { + // Try the next registry + } catch (ErrorResultException exc) { + var json = QueryResultJson.error(exc.getMessage()); + var status = exc.getStatus() != null ? exc.getStatus() : HttpStatus.BAD_REQUEST; + return new ResponseEntity<>(json, status); + } + } + return ResponseEntity.ok(result); + } + @PostMapping( path = "/api/-/namespace/create", consumes = MediaType.APPLICATION_JSON_VALUE, @@ -308,18 +365,23 @@ private int mergeSearchResults(SearchResultJson result, List en @ApiResponse( code = 201, message = "Successfully created the namespace", - examples = @Example(@ExampleProperty(value="{ \"success\": \"Created namespace foobar\" }", mediaType = "application/json")) + examples = @Example(@ExampleProperty(value="{ \"success\": \"Created namespace foobar\" }", mediaType = "application/json")), + responseHeaders = @ResponseHeader( + name = "Location", + description = "The URL of the namespace metadata", + response = String.class + ) ), @ApiResponse( - code = 200, + code = 400, message = "The namespace could not be created", examples = @Example(@ExampleProperty(value="{ \"error\": \"Invalid access token.\" }", mediaType = "application/json")) ) }) - public ResponseEntity createNamespace(@RequestBody(required = false) @ApiParam("Describes the namespace to create") - NamespaceJson namespace, - @RequestParam @ApiParam("A personal access token") - String token) { + public ResponseEntity createNamespace(@RequestBody @ApiParam("Describes the namespace to create") + NamespaceJson namespace, + @RequestParam @ApiParam("A personal access token") + String token) { if (namespace == null) { return ResponseEntity.ok(ResultJson.error("No JSON input.")); } @@ -330,9 +392,13 @@ public ResponseEntity createNamespace(@RequestBody(required = false) var json = local.createNamespace(namespace, token); var serverUrl = UrlUtil.getBaseUrl(); var url = UrlUtil.createApiUrl(serverUrl, "api", namespace.name); - return new ResponseEntity<>(json, location(url), HttpStatus.CREATED); + return ResponseEntity.status(HttpStatus.CREATED) + .location(URI.create(url)) + .body(json); } catch (ErrorResultException exc) { - return ResponseEntity.ok(ResultJson.error(exc.getMessage())); + var json = ResultJson.error(exc.getMessage()); + var status = exc.getStatus() != null ? exc.getStatus() : HttpStatus.BAD_REQUEST; + return new ResponseEntity<>(json, status); } } @@ -353,23 +419,32 @@ public ResponseEntity createNamespace(@RequestBody(required = false) @ApiResponses({ @ApiResponse( code = 201, - message = "Successfully published the extension" + message = "Successfully published the extension", + responseHeaders = @ResponseHeader( + name = "Location", + description = "The URL of the extension metadata", + response = String.class + ) ), @ApiResponse( - code = 200, + code = 400, message = "The extension could not be published", examples = @Example(@ExampleProperty(value="{ \"error\": \"Invalid access token.\" }", mediaType = "application/json")) ) }) public ResponseEntity publish(InputStream content, - @RequestParam @ApiParam("A personal access token") String token) { + @RequestParam @ApiParam("A personal access token") String token) { try { var json = local.publish(content, token); var serverUrl = UrlUtil.getBaseUrl(); var url = UrlUtil.createApiUrl(serverUrl, "api", json.namespace, json.name, json.version); - return new ResponseEntity<>(json, location(url), HttpStatus.CREATED); + return ResponseEntity.status(HttpStatus.CREATED) + .location(URI.create(url)) + .body(json); } catch (ErrorResultException exc) { - return ResponseEntity.ok(ExtensionJson.error(exc.getMessage())); + var json = ExtensionJson.error(exc.getMessage()); + var status = exc.getStatus() != null ? exc.getStatus() : HttpStatus.BAD_REQUEST; + return new ResponseEntity<>(json, status); } } @@ -383,22 +458,26 @@ public ResponseEntity postReview(@RequestBody(required = false) Revi @PathVariable String namespace, @PathVariable String extension) { if (review == null) { - return ResponseEntity.ok(ResultJson.error("No JSON input.")); + var json = ResultJson.error("No JSON input."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } if (review.rating < 0 || review.rating > 5) { - return ResponseEntity.ok(ResultJson.error("The rating must be an integer number between 0 and 5.")); + var json = ResultJson.error("The rating must be an integer number between 0 and 5."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } if (review.title != null && review.title.length() > REVIEW_TITLE_SIZE) { - return ResponseEntity.ok(ResultJson.error("The title must not be longer than " + REVIEW_TITLE_SIZE + " characters.")); + var json = ResultJson.error("The title must not be longer than " + REVIEW_TITLE_SIZE + " characters."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } if (review.comment != null && review.comment.length() > REVIEW_COMMENT_SIZE) { - return ResponseEntity.ok(ResultJson.error("The review must not be longer than " + REVIEW_COMMENT_SIZE + " characters.")); + var json = ResultJson.error("The review must not be longer than " + REVIEW_COMMENT_SIZE + " characters."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } var json = local.postReview(review, namespace, extension); if (json.error == null) { return new ResponseEntity<>(json, HttpStatus.CREATED); } else { - return ResponseEntity.ok(json); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } } @@ -407,19 +486,14 @@ public ResponseEntity postReview(@RequestBody(required = false) Revi produces = MediaType.APPLICATION_JSON_VALUE ) @ApiIgnore - public ResultJson deleteReview(@PathVariable String namespace, - @PathVariable String extension) { - return local.deleteReview(namespace, extension); - } - - private HttpHeaders location(String value) { - try { - var headers = new HttpHeaders(); - headers.setLocation(new URI(value)); - return headers; - } catch (URISyntaxException exc) { - throw new RuntimeException(exc); - } + public ResponseEntity deleteReview(@PathVariable String namespace, + @PathVariable String extension) { + var json = local.deleteReview(namespace, extension); + if (json.error == null) { + return ResponseEntity.ok(json); + } else { + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); + } } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/UpstreamRegistryService.java b/server/src/main/java/org/eclipse/openvsx/UpstreamRegistryService.java index 8766fc31b..da068c61c 100644 --- a/server/src/main/java/org/eclipse/openvsx/UpstreamRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/UpstreamRegistryService.java @@ -30,6 +30,8 @@ import org.eclipse.openvsx.json.ExtensionJson; import org.eclipse.openvsx.json.NamespaceJson; +import org.eclipse.openvsx.json.QueryParamJson; +import org.eclipse.openvsx.json.QueryResultJson; import org.eclipse.openvsx.json.ReviewListJson; import org.eclipse.openvsx.json.SearchResultJson; import org.eclipse.openvsx.search.SearchService; @@ -139,6 +141,17 @@ public SearchResultJson search(SearchService.Options options) { throw exc; } } + + @Override + public QueryResultJson query(QueryParamJson param) { + try { + String requestUrl = createApiUrl(upstreamUrl, "api", "-", "query"); + return restTemplate.postForObject(requestUrl, param, QueryResultJson.class); + } catch (RestClientException exc) { + handleError(exc); + throw exc; + } + } private void handleError(Throwable exc) throws RuntimeException { if (exc instanceof HttpStatusCodeException) { diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 9c1df7fe3..0f9de4543 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -57,6 +57,11 @@ public class UserAPI { @Autowired UserService users; + /** + * This endpoint is used to check whether there is a logged-in user. For this reason, it + * does not return a 403 status, but an OK status with JSON body when no user data is + * available. This is to avoid unnecessary network error logging in the browser console. + */ @GetMapping( path = "/user", produces = MediaType.APPLICATION_JSON_VALUE @@ -114,14 +119,15 @@ public List getAccessTokens() { path = "/user/token/create", produces = MediaType.APPLICATION_JSON_VALUE ) - @Transactional(rollbackOn = ResponseStatusException.class) + @Transactional public ResponseEntity createAccessToken(@RequestParam(required = false) String description) { if (description != null && description.length() > TOKEN_DESCRIPTION_SIZE) { - return ResponseEntity.ok(AccessTokenJson.error("The description must not be longer than " + TOKEN_DESCRIPTION_SIZE + " characters.")); + var json = AccessTokenJson.error("The description must not be longer than " + TOKEN_DESCRIPTION_SIZE + " characters."); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } var principal = users.getOAuth2Principal(); if (principal == null) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return new ResponseEntity<>(HttpStatus.FORBIDDEN); } var user = users.updateUser(principal); var token = new PersonalAccessToken(); @@ -143,8 +149,8 @@ public ResponseEntity createAccessToken(@RequestParam(required path = "/user/token/delete/{id}", produces = MediaType.APPLICATION_JSON_VALUE ) - @Transactional(rollbackOn = ResponseStatusException.class) - public ResultJson deleteAccessToken(@PathVariable long id) { + @Transactional + public ResponseEntity deleteAccessToken(@PathVariable long id) { var principal = users.getOAuth2Principal(); if (principal == null) { throw new ResponseStatusException(HttpStatus.FORBIDDEN); @@ -152,10 +158,12 @@ public ResultJson deleteAccessToken(@PathVariable long id) { var user = users.updateUser(principal); var token = repositories.findAccessToken(id); if (token == null || !token.isActive() || !token.getUser().equals(user)) { - return ResultJson.error("Token does not exist."); + var json = ResultJson.error("Token does not exist."); + return new ResponseEntity<>(json, HttpStatus.NOT_FOUND); } token.setActive(false); - return ResultJson.success("Deleted access token for user " + user.getLoginName() + "."); + var json = ResultJson.success("Deleted access token for user " + user.getLoginName() + "."); + return ResponseEntity.ok(json); } @GetMapping( @@ -213,17 +221,19 @@ public List getNamespaceMembers(@PathVariable String na path = "/user/namespace/{namespace}/role", produces = MediaType.APPLICATION_JSON_VALUE ) - public ResultJson setNamespaceMember(@PathVariable String namespace, @RequestParam String user, + public ResponseEntity setNamespaceMember(@PathVariable String namespace, @RequestParam String user, @RequestParam String role, @RequestParam(required = false) String provider) { var principal = users.getOAuth2Principal(); if (principal == null) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return new ResponseEntity<>(HttpStatus.FORBIDDEN); } try { var requestingUser = users.updateUser(principal); - return users.setNamespaceMember(requestingUser, namespace, provider, user, role); + var json = users.setNamespaceMember(requestingUser, namespace, provider, user, role); + return ResponseEntity.ok(json); } catch (ErrorResultException exc) { - return ResultJson.error(exc.getMessage()); + var json = ResultJson.error(exc.getMessage()); + return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } } diff --git a/server/src/main/java/org/eclipse/openvsx/json/QueryParamJson.java b/server/src/main/java/org/eclipse/openvsx/json/QueryParamJson.java new file mode 100644 index 000000000..eabcf93e3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/QueryParamJson.java @@ -0,0 +1,46 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel( + value = "QueryParam", + description = "Metadata query parameter" +) +@JsonInclude(Include.NON_NULL) +public class QueryParamJson { + + @ApiModelProperty("Name of a namespace") + public String namespaceName; + + @ApiModelProperty("Name of an extension") + public String extensionName; + + @ApiModelProperty("Version of an extension") + public String extensionVersion; + + @ApiModelProperty("Identifier in the form {namespace}.{extension}") + public String extensionId; + + @ApiModelProperty("Universally unique identifier of an extension") + public String extensionUuid; + + @ApiModelProperty("Universally unique identifier of a namespace") + public String namespaceUuid; + + @ApiModelProperty("Whether to include all versions of an extension") + public boolean includeAllVersions; + +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/json/QueryResultJson.java b/server/src/main/java/org/eclipse/openvsx/json/QueryResultJson.java new file mode 100644 index 000000000..443a9c0f3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/QueryResultJson.java @@ -0,0 +1,36 @@ +/******************************************************************************** + * Copyright (c) 2020 TypeFox and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel( + value = "QueryResult", + description = "Metadata query result" +) +@JsonInclude(Include.NON_NULL) +public class QueryResultJson extends ResultJson { + + public static QueryResultJson error(String message) { + var result = new QueryResultJson(); + result.error = message; + return result; + } + + @ApiModelProperty("Extensions that match the given query (may be empty)") + public List extensions; + +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java index 3ea6d498a..d00091b14 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java @@ -18,6 +18,8 @@ public interface ExtensionRepository extends Repository { + Streamable findByNameIgnoreCase(String name); + Streamable findByNamespace(Namespace namespace); Streamable findByNamespaceOrderByNameAsc(Namespace namespace); diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/NamespaceRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/NamespaceRepository.java index cc9ebca46..45f70d336 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/NamespaceRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/NamespaceRepository.java @@ -15,10 +15,10 @@ public interface NamespaceRepository extends Repository { - Namespace findByName(String name); - Namespace findByNameIgnoreCase(String name); + Namespace findByPublicId(String publicId); + long count(); } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 23a03db92..61bd384a3 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -43,6 +43,10 @@ public Namespace findNamespace(String name) { return namespaceRepo.findByNameIgnoreCase(name); } + public Namespace findNamespaceByPublicId(String publicId) { + return namespaceRepo.findByPublicId(publicId); + } + public long countNamespaces() { return namespaceRepo.count(); } @@ -63,6 +67,10 @@ public Streamable findExtensions(Namespace namespace) { return extensionRepo.findByNamespaceOrderByNameAsc(namespace); } + public Streamable findExtensions(String name) { + return extensionRepo.findByNameIgnoreCase(name); + } + public Streamable findAllExtensions() { return extensionRepo.findAll(); } diff --git a/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java b/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java index cc3e5a692..a9775d480 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java @@ -17,6 +17,7 @@ import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @@ -56,7 +57,11 @@ protected void configure(HttpSecurity http) throws Exception { // Publishing is done only via explicit access tokens, so we don't need CSRF protection here. http.csrf() - .ignoringAntMatchers("/api/-/publish", "/api/-/namespace/create", "/admin/**", "/vscode/**"); + .ignoringAntMatchers("/api/-/publish", "/api/-/namespace/create", "/api/-/query", "/admin/**", "/vscode/**"); + + // Respond with 403 status when the user is not logged in + http.exceptionHandling() + .authenticationEntryPoint(new Http403ForbiddenEntryPoint()); } @Override diff --git a/server/src/main/java/org/eclipse/openvsx/util/ErrorResultException.java b/server/src/main/java/org/eclipse/openvsx/util/ErrorResultException.java index 2a39b3f14..3a41ec43e 100644 --- a/server/src/main/java/org/eclipse/openvsx/util/ErrorResultException.java +++ b/server/src/main/java/org/eclipse/openvsx/util/ErrorResultException.java @@ -9,22 +9,37 @@ ********************************************************************************/ package org.eclipse.openvsx.util; +import org.springframework.http.HttpStatus; + /** * Throw this exception to reply with a JSON object of the form + * *
- * { "error": "«message»" }
- * 
{ + e.namespace = "foo"; + e.name = "bar"; + e.version = "1"; + e.namespaceAccess = "public"; + e.timestamp = "2000-01-01T10:00Z"; + e.displayName = "Foo Bar"; + }))); + } + + @Test + public void testQueryNamespace() throws Exception { + mockExtension(); + mockMvc.perform(post("/api/-/query") + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"namespaceName\": \"foo\" }")) + .andExpect(status().isOk()) + .andExpect(content().json(queryResultJson(e -> { + e.namespace = "foo"; + e.name = "bar"; + e.version = "1"; + e.namespaceAccess = "public"; + e.timestamp = "2000-01-01T10:00Z"; + e.displayName = "Foo Bar"; + }))); + } + + @Test + public void testQueryUnknownExtension() throws Exception { + mockExtension(); + Mockito.when(repositories.findExtensions("baz")).thenReturn(Streamable.empty()); + mockMvc.perform(post("/api/-/query") + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"extensionName\": \"baz\" }")) + .andExpect(status().isOk()) + .andExpect(content().json("{ \"extensions\": [] }")); + } + + @Test + public void testQueryExtensionId() throws Exception { + mockExtension(); + mockMvc.perform(post("/api/-/query") + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"extensionId\": \"foo.bar\" }")) + .andExpect(status().isOk()) + .andExpect(content().json(queryResultJson(e -> { + e.namespace = "foo"; + e.name = "bar"; + e.version = "1"; + e.namespaceAccess = "public"; + e.timestamp = "2000-01-01T10:00Z"; + e.displayName = "Foo Bar"; + }))); + } + + @Test + public void testQueryExtensionUuid() throws Exception { + mockExtension(); + mockMvc.perform(post("/api/-/query") + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"extensionUuid\": \"5678\" }")) + .andExpect(status().isOk()) + .andExpect(content().json(queryResultJson(e -> { + e.namespace = "foo"; + e.name = "bar"; + e.version = "1"; + e.namespaceAccess = "public"; + e.timestamp = "2000-01-01T10:00Z"; + e.displayName = "Foo Bar"; + }))); + } + + @Test + public void testQueryNamespaceUuid() throws Exception { + mockExtension(); + mockMvc.perform(post("/api/-/query") + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"namespaceUuid\": \"1234\" }")) + .andExpect(status().isOk()) + .andExpect(content().json(queryResultJson(e -> { + e.namespace = "foo"; + e.name = "bar"; + e.version = "1"; + e.namespaceAccess = "public"; + e.timestamp = "2000-01-01T10:00Z"; + e.displayName = "Foo Bar"; + }))); + } + @Test public void testCreateNamespace() throws Exception { mockAccessToken(); @@ -281,7 +377,7 @@ public void testCreateNamespaceInvalidName() throws Exception { mockMvc.perform(post("/api/-/namespace/create?token={token}", "my_token") .contentType(MediaType.APPLICATION_JSON) .content(namespaceJson(n -> { n.name = "foo.bar"; }))) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Invalid namespace name: foo.bar"))); } @@ -292,7 +388,7 @@ public void testCreateNamespaceInactiveToken() throws Exception { mockMvc.perform(post("/api/-/namespace/create?token={token}", "my_token") .contentType(MediaType.APPLICATION_JSON) .content(namespaceJson(n -> { n.name = "foobar"; }))) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Invalid access token."))); } @@ -307,7 +403,7 @@ public void testCreateExistingNamespace() throws Exception { mockMvc.perform(post("/api/-/namespace/create?token={token}", "my_token") .contentType(MediaType.APPLICATION_JSON) .content(namespaceJson(n -> { n.name = "foobar"; }))) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Namespace already exists: foobar"))); } @@ -336,7 +432,7 @@ public void testPublishInactiveToken() throws Exception { mockMvc.perform(post("/api/-/publish?token={token}", "my_token") .contentType(MediaType.APPLICATION_OCTET_STREAM) .content(bytes)) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Invalid access token."))); } @@ -347,7 +443,7 @@ public void testPublishUnknownNamespace() throws Exception { mockMvc.perform(post("/api/-/publish?token={token}", "my_token") .contentType(MediaType.APPLICATION_OCTET_STREAM) .content(bytes)) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Unknown publisher: foo" + "\nUse the 'create-namespace' command to create a namespace corresponding to your publisher name."))); } @@ -414,7 +510,7 @@ public void testPublishRestrictedUnrelated() throws Exception { mockMvc.perform(post("/api/-/publish?token={token}", "my_token") .contentType(MediaType.APPLICATION_OCTET_STREAM) .content(bytes)) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Insufficient access rights for publisher: foo"))); } @@ -425,7 +521,7 @@ public void testPublishExistingExtension() throws Exception { mockMvc.perform(post("/api/-/publish?token={token}", "my_token") .contentType(MediaType.APPLICATION_OCTET_STREAM) .content(bytes)) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Extension foo.bar version 1 is already published."))); } @@ -436,7 +532,7 @@ public void testPublishInvalidName() throws Exception { mockMvc.perform(post("/api/-/publish?token={token}", "my_token") .contentType(MediaType.APPLICATION_OCTET_STREAM) .content(bytes)) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Invalid extension name: b.a.r"))); } @@ -447,7 +543,7 @@ public void testPublishInvalidVersion() throws Exception { mockMvc.perform(post("/api/-/publish?token={token}", "my_token") .contentType(MediaType.APPLICATION_OCTET_STREAM) .content(bytes)) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("The version string 'latest' is reserved."))); } @@ -489,7 +585,7 @@ public void testPostReviewInvalidRating() throws Exception { .content(reviewJson(r -> { r.rating = 100; })).with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("The rating must be an integer number between 0 and 5."))); } @@ -501,7 +597,7 @@ public void testPostReviewUnknownExtension() throws Exception { .content(reviewJson(r -> { r.rating = 3; })).with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Extension not found: foo.bar"))); } @@ -524,7 +620,7 @@ public void testPostExistingReview() throws Exception { .content(reviewJson(r -> { r.rating = 3; })).with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("You must not submit more than one review for an extension."))); } @@ -550,7 +646,7 @@ public void testDeleteReview() throws Exception { } @Test - public void testDeletReviewNotLoggedIn() throws Exception { + public void testDeleteReviewNotLoggedIn() throws Exception { mockMvc.perform(post("/api/{namespace}/{extension}/review/delete", "foo", "bar").with(csrf())) .andExpect(status().isForbidden()); } @@ -559,7 +655,7 @@ public void testDeletReviewNotLoggedIn() throws Exception { public void testDeleteReviewUnknownExtension() throws Exception { mockUserData(); mockMvc.perform(post("/api/{namespace}/{extension}/review/delete", "foo", "bar").with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("Extension not found: foo.bar"))); } @@ -574,7 +670,7 @@ public void testDeleteNonExistingReview() throws Exception { .thenReturn(Streamable.empty()); mockMvc.perform(post("/api/{namespace}/{extension}/review/delete", "foo", "bar").with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("You have not submitted any review yet."))); } @@ -600,9 +696,11 @@ private String namespaceJson(Consumer content) throws JsonProcess private ExtensionVersion mockExtension() { var namespace = new Namespace(); namespace.setName("foo"); + namespace.setPublicId("1234"); var extension = new Extension(); extension.setName("bar"); extension.setNamespace(namespace); + extension.setPublicId("5678"); var extVersion = new ExtensionVersion(); extension.setLatest(extVersion); extVersion.setExtension(extension); @@ -615,6 +713,8 @@ private ExtensionVersion mockExtension() { .thenReturn(extVersion); Mockito.when(repositories.findVersions(extension)) .thenReturn(Streamable.of(extVersion)); + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.of(extension)); Mockito.when(repositories.getVersionStrings(extension)) .thenReturn(Streamable.of(extVersion.getVersion())); Mockito.when(repositories.countMemberships(namespace, NamespaceMembership.ROLE_OWNER)) @@ -623,6 +723,14 @@ private ExtensionVersion mockExtension() { .thenReturn(0l); Mockito.when(repositories.findFilesByType(eq(extVersion), anyCollection())) .thenReturn(Streamable.empty()); + Mockito.when(repositories.findNamespace("foo")) + .thenReturn(namespace); + Mockito.when(repositories.findExtensions("bar")) + .thenReturn(Streamable.of(extension)); + Mockito.when(repositories.findNamespaceByPublicId("1234")) + .thenReturn(namespace); + Mockito.when(repositories.findExtensionByPublicId("5678")) + .thenReturn(extension); return extVersion; } @@ -632,6 +740,10 @@ private String extensionJson(Consumer content) throws JsonProcess return new ObjectMapper().writeValueAsString(json); } + private String queryResultJson(Consumer content) throws JsonProcessingException { + return "{\"extensions\":[" + extensionJson(content) + "]}"; + } + private FileResource mockReadme() { var extVersion = mockExtension(); var resource = new FileResource(); diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index c1d76193b..6a7f9a5a2 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -142,7 +142,7 @@ public void testDeleteAccessTokenInactive() throws Exception { .thenReturn(token); mockMvc.perform(post("/user/token/delete/{id}", 100).with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isNotFound()) .andExpect(content().json(errorJson("Token does not exist."))); } @@ -159,7 +159,7 @@ public void testDeleteAccessTokenWrongUser() throws Exception { .thenReturn(token); mockMvc.perform(post("/user/token/delete/{id}", 100).with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isNotFound()) .andExpect(content().json(errorJson("Token does not exist."))); } @@ -310,7 +310,7 @@ public void testAddNamespaceMemberNotOwner() throws Exception { mockMvc.perform(post("/user/namespace/{namespace}/role?user={user}&role={role}", "foobar", "other_user", "contributor").with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("You must be an owner of this namespace."))); } @@ -340,7 +340,7 @@ public void testChangeNamespaceMemberSameRole() throws Exception { mockMvc.perform(post("/user/namespace/{namespace}/role?user={user}&role={role}", "foobar", "other_user", "contributor").with(csrf())) - .andExpect(status().isOk()) + .andExpect(status().isBadRequest()) .andExpect(content().json(errorJson("User other_user already has the role contributor."))); } diff --git a/webui/package.json b/webui/package.json index bd762fa29..ecbc4bf3d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -90,6 +90,7 @@ "build:default": "webpack --config ./configs/webpack.config.js --mode production", "watch:default": "webpack --config ./configs/webpack.config.js --mode development --watch", "start:default": "node lib/default/server", + "copy2server": "cp -rfv static ../server/src/dev/resources/", "publish:next": "yarn publish --new-version \"$(semver $npm_package_version -i minor)-next.$(git rev-parse --short HEAD)\" --tag next --no-git-tag-version", "publish:latest": "yarn publish --tag latest" }