From 981b726a12b45c5e2525692207cff00999073914 Mon Sep 17 00:00:00 2001 From: Stephane Nicoll Date: Fri, 27 Jan 2017 09:58:50 +0100 Subject: [PATCH] Polish contribution This commit makes sure that each dependency link is HAL compliant (like the project types in the metadata). Links are grouped by relation with well known relations to be defined (i.e. 'how-to', 'reference', 'home' and so forth). Each link can be "templated" (in the HAL sense) and only `{bootVersion}` is supported at the moment. This is useful if a precise documentation section should reference to the actual Stpring Boot version chosen by the user. Closes gh-279 --- .../initializr/metadata/Dependency.groovy | 7 +- .../io/spring/initializr/metadata/Link.groovy | 90 ++++++++++++++++-- .../metadata/DependencyTests.groovy | 11 ++- .../initializr/metadata/LinkTests.groovy | 92 ++++++++++++++++++ .../resources/application-test-default.yml | 14 +++ .../DependencyMetadataV21JsonMapper.groovy | 3 - .../InitializrMetadataV21JsonMapper.groovy | 2 +- .../initializr/web/mapper/LinkMapper.groovy | 73 ++++++++++++++ .../initializr/web/ui/UiController.groovy | 4 - .../InitializrMetadataJsonMapperTests.groovy | 17 +++- .../web/mapper/LinkMapperTests.groovy | 95 +++++++++++++++++++ .../metadata/config/test-default.json | 31 +++++- .../metadata/test-default-2.1.0.json | 28 +++++- 13 files changed, 444 insertions(+), 23 deletions(-) create mode 100644 initializr-generator/src/test/groovy/io/spring/initializr/metadata/LinkTests.groovy create mode 100644 initializr-web/src/main/groovy/io/spring/initializr/web/mapper/LinkMapper.groovy create mode 100644 initializr-web/src/test/groovy/io/spring/initializr/web/mapper/LinkMapperTests.groovy diff --git a/initializr-generator/src/main/groovy/io/spring/initializr/metadata/Dependency.groovy b/initializr-generator/src/main/groovy/io/spring/initializr/metadata/Dependency.groovy index ebcd7e93..9efe1027 100644 --- a/initializr-generator/src/main/groovy/io/spring/initializr/metadata/Dependency.groovy +++ b/initializr-generator/src/main/groovy/io/spring/initializr/metadata/Dependency.groovy @@ -91,8 +91,6 @@ class Dependency extends MetadataElement { String repository - List links = [] - @JsonInclude(JsonInclude.Include.NON_DEFAULT) int weight @@ -104,6 +102,8 @@ class Dependency extends MetadataElement { List keywords = [] + List links = [] + void setScope(String scope) { if (!SCOPE_ALL.contains(scope)) { throw new InvalidInitializrMetadataException("Invalid scope $scope must be one of $SCOPE_ALL") @@ -163,6 +163,9 @@ class Dependency extends MetadataElement { "Invalid dependency, id should have the form groupId:artifactId[:version] but got $id") } } + links.forEach { l -> + l.resolve() + } updateVersionRanges(VersionParser.DEFAULT) } diff --git a/initializr-generator/src/main/groovy/io/spring/initializr/metadata/Link.groovy b/initializr-generator/src/main/groovy/io/spring/initializr/metadata/Link.groovy index afa54bc3..ed9ebdc1 100644 --- a/initializr-generator/src/main/groovy/io/spring/initializr/metadata/Link.groovy +++ b/initializr-generator/src/main/groovy/io/spring/initializr/metadata/Link.groovy @@ -1,5 +1,5 @@ /* - * Copyright 2012-2015 the original author or authors. + * Copyright 2012-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,16 +16,92 @@ package io.spring.initializr.metadata +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude +import groovy.transform.ToString + /** - * @author Dave Syer + * Metadata for a link. Each link has a "relation" that potentially attaches a strong + * semantic to the nature of the link. The URI of the link itself can be templated by + * including variables in the form `{variableName}`. + *

+ * An actual {@code URI} can be generated using {@code expand}, providing a mapping for + * those variables. * + * @author Dave Syer + * @author Stephane Nicoll */ +@ToString(ignoreNulls = true, includePackage = false) class Link { - - String id - - URL url - + + private static final String VARIABLE_REGEX = "\\{(\\w+)\\}"; + + /** + * The relation of the link. + */ + String rel; + + /** + * The URI the link is pointing to. + */ + String href + + /** + * Specify if the URI is templated. + */ + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + boolean templated + + @JsonIgnore + final Set templateVariables = [] + + /** + * A description of the link. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) String description + Set getTemplateVariables() { + Collections.unmodifiableSet(templateVariables) + } + + void setHref(String href) { + this.href = href + } + + void resolve() { + if (!rel) { + throw new InvalidInitializrMetadataException( + "Invalid link $this: rel attribute is mandatory") + } + if (!href) { + throw new InvalidInitializrMetadataException( + "Invalid link $this: href attribute is mandatory") + } + def matcher = (href =~ VARIABLE_REGEX) + while (matcher.find()) { + def variable = matcher.group(1) + this.templateVariables << variable + } + this.templated = this.templateVariables + } + + /** + * Expand the link using the specified parameters. + * @param parameters the parameters value + * @return an URI where all variables have been expanded + */ + URI expand(Map parameters) { + String result = href + templateVariables.forEach { var -> + Object value = parameters[var] + if (!value) { + throw new IllegalArgumentException( + "Could not explan $href, missing value for '$var'") + } + result = result.replace("{$var}", value.toString()) + } + new URI(result) + } + } diff --git a/initializr-generator/src/test/groovy/io/spring/initializr/metadata/DependencyTests.groovy b/initializr-generator/src/test/groovy/io/spring/initializr/metadata/DependencyTests.groovy index fc794c34..ee521a11 100644 --- a/initializr-generator/src/test/groovy/io/spring/initializr/metadata/DependencyTests.groovy +++ b/initializr-generator/src/test/groovy/io/spring/initializr/metadata/DependencyTests.groovy @@ -1,5 +1,5 @@ /* - * Copyright 2012-2016 the original author or authors. + * Copyright 2012-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -123,6 +123,15 @@ class DependencyTests { dependency.resolve() } + @Test + void invalidLink() { + def dependency = new Dependency(id : 'foo') + dependency.links << new Link(href: 'https://example.com') + + thrown.expect(InvalidInitializrMetadataException) + dependency.resolve() + } + @Test void generateIdWithNoGroupId() { def dependency = new Dependency() diff --git a/initializr-generator/src/test/groovy/io/spring/initializr/metadata/LinkTests.groovy b/initializr-generator/src/test/groovy/io/spring/initializr/metadata/LinkTests.groovy new file mode 100644 index 00000000..58afbfe2 --- /dev/null +++ b/initializr-generator/src/test/groovy/io/spring/initializr/metadata/LinkTests.groovy @@ -0,0 +1,92 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * 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 io.spring.initializr.metadata + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExpectedException + +/** + * Tests for {@link Link}. + * + * @author Stephane Nicoll + */ +class LinkTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none() + + @Test + void resolveInvalidLinkNoRel() { + def link = new Link(href: 'https://example.com') + thrown.expect(InvalidInitializrMetadataException) + link.resolve() + } + + @Test + void resolveInvalidLinkNoHref() { + def link = new Link(rel: 'reference', description: 'foo doc') + + thrown.expect(InvalidInitializrMetadataException) + link.resolve() + } + + @Test + void resolveLinkNoVariables() { + def link = new Link(rel: 'reference', href: 'https://example.com/2') + link.resolve() + assert !link.templated + assert link.templateVariables.size() == 0 + } + + @Test + void resolveLinkWithVariables() { + def link = new Link(rel: 'reference', href: 'https://example.com/{a}/2/{b}') + link.resolve() + assert link.templated + assert link.templateVariables.size() == 2 + assert link.templateVariables.contains('a') + assert link.templateVariables.contains('b') + } + + @Test + void expandLink() { + def link = new Link(rel: 'reference', href: 'https://example.com/{a}/2/{b}') + link.resolve() + assert link.expand(['a': 'test', 'b': 'another']) == + new URI('https://example.com/test/2/another') + } + + @Test + void expandLinkWithSameAttributeAtTwoPlaces() { + def link = new Link(rel: 'reference', href: 'https://example.com/{a}/2/{a}') + link.resolve() + assert link.expand(['a': 'test', 'b': 'another']) == + new URI('https://example.com/test/2/test') + } + + @Test + void expandLinkMissingVariable() { + def link = new Link(rel: 'reference', href: 'https://example.com/{a}/2/{b}') + link.resolve() + + thrown.expect(IllegalArgumentException) + thrown.expectMessage("missing value for 'b'") + link.expand(['a': 'test']) + } + +} diff --git a/initializr-generator/src/test/resources/application-test-default.yml b/initializr-generator/src/test/resources/application-test-default.yml index 320384a3..dae8c3e0 100644 --- a/initializr-generator/src/test/resources/application-test-default.yml +++ b/initializr-generator/src/test/resources/application-test-default.yml @@ -39,6 +39,12 @@ initializr: description: Web dependency description facets: - web + links: + - rel: guide + href: https://example.com/guide + description: Building a RESTful Web Service + - rel: reference + href: https://example.com/doc - name: Security id: security - name: Data JPA @@ -55,6 +61,14 @@ initializr: keywords: - thefoo - dafoo + links: + - rel: guide + href: https://example.com/guide1 + - rel: reference + href: https://example.com/{bootVersion}/doc + - rel: guide + href: https://example.com/guide2 + description: Some guide for foo - name: Bar id: org.acme:bar version: 2.1.0 diff --git a/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/DependencyMetadataV21JsonMapper.groovy b/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/DependencyMetadataV21JsonMapper.groovy index a1049b1f..afc0b09d 100644 --- a/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/DependencyMetadataV21JsonMapper.groovy +++ b/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/DependencyMetadataV21JsonMapper.groovy @@ -57,9 +57,6 @@ class DependencyMetadataV21JsonMapper implements DependencyMetadataJsonMapper { if (dep.repository) { result.repository = dep.repository } - if (dep.links) { - result.links = dep.links - } result } diff --git a/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/InitializrMetadataV21JsonMapper.groovy b/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/InitializrMetadataV21JsonMapper.groovy index 618adc4b..fa40d9b8 100644 --- a/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/InitializrMetadataV21JsonMapper.groovy +++ b/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/InitializrMetadataV21JsonMapper.groovy @@ -53,7 +53,7 @@ class InitializrMetadataV21JsonMapper extends InitializrMetadataV2JsonMapper { content['versionRange'] = dependency.versionRange } if (dependency.links) { - content.links = dependency.links + content._links = LinkMapper.mapLinks(dependency.links) } content } diff --git a/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/LinkMapper.groovy b/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/LinkMapper.groovy new file mode 100644 index 00000000..535690fd --- /dev/null +++ b/initializr-web/src/main/groovy/io/spring/initializr/web/mapper/LinkMapper.groovy @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * 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 io.spring.initializr.web.mapper + +import io.spring.initializr.metadata.Link + +/** + * Generate a json representation for {@link Link} + * + * @author Stephane Nicoll + */ +class LinkMapper { + + /** + * Map the specified links to a json model. If several links share + * the same relation, they are grouped together. + * @param links the links to map + * @return a model for the specified links + */ + static mapLinks(List links) { + def result = [:] + Map> byRel = new LinkedHashMap<>() + links.each { + def relLinks = byRel[it.rel] + if (!relLinks) { + relLinks = [] + byRel[it.rel] = relLinks + } + relLinks.add(it) + } + byRel.forEach { rel, l -> + if (l.size() == 1) { + def root = [:] + mapLink(l[0], root) + result[rel] = root + } else { + def root = [] + l.each { + def model = [:] + mapLink(it, model) + root << model + } + result[rel] = root + } + } + result + } + + private static mapLink(Link link, def model) { + model.href = link.href + if (link.templated) { + model.templated = true + } + if (link.description) { + model.title = link.description + } + } + +} diff --git a/initializr-web/src/main/groovy/io/spring/initializr/web/ui/UiController.groovy b/initializr-web/src/main/groovy/io/spring/initializr/web/ui/UiController.groovy index ff5f3c9f..4bc97cc4 100644 --- a/initializr-web/src/main/groovy/io/spring/initializr/web/ui/UiController.groovy +++ b/initializr-web/src/main/groovy/io/spring/initializr/web/ui/UiController.groovy @@ -82,10 +82,6 @@ class UiController { if (d.description) { result.description = d.description } - if (d.links) { - result.url = d.links[0].url - result.links = d.links - } if (d.weight) { result.weight = d.weight } diff --git a/initializr-web/src/test/groovy/io/spring/initializr/web/mapper/InitializrMetadataJsonMapperTests.groovy b/initializr-web/src/test/groovy/io/spring/initializr/web/mapper/InitializrMetadataJsonMapperTests.groovy index a20f2f6f..8e9eaba4 100644 --- a/initializr-web/src/test/groovy/io/spring/initializr/web/mapper/InitializrMetadataJsonMapperTests.groovy +++ b/initializr-web/src/test/groovy/io/spring/initializr/web/mapper/InitializrMetadataJsonMapperTests.groovy @@ -1,5 +1,5 @@ /* - * Copyright 2012-2015 the original author or authors. + * Copyright 2012-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,9 @@ package io.spring.initializr.web.mapper import groovy.json.JsonSlurper +import io.spring.initializr.metadata.Dependency import io.spring.initializr.metadata.InitializrMetadata +import io.spring.initializr.metadata.Link import io.spring.initializr.test.metadata.InitializrMetadataTestBuilder import org.junit.Test @@ -52,4 +54,17 @@ class InitializrMetadataJsonMapperTests { result._links.foo.href } + @Test + void keepLinksOrdering() { + def dependency = new Dependency(id: 'foo') + dependency.links << new Link(rel: 'guide', href: 'https://example.com/how-to') + dependency.links << new Link(rel: 'reference', href: 'https://example.com/doc') + InitializrMetadata metadata = InitializrMetadataTestBuilder.withDefaults() + .addDependencyGroup('test', dependency).build() + def json = jsonMapper.write(metadata, null) + def first = json.indexOf('https://example.com/how-to') + def second = json.indexOf('https://example.com/doc') + assert first < second + } + } diff --git a/initializr-web/src/test/groovy/io/spring/initializr/web/mapper/LinkMapperTests.groovy b/initializr-web/src/test/groovy/io/spring/initializr/web/mapper/LinkMapperTests.groovy new file mode 100644 index 00000000..f57ed38d --- /dev/null +++ b/initializr-web/src/test/groovy/io/spring/initializr/web/mapper/LinkMapperTests.groovy @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * 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 io.spring.initializr.web.mapper + +import io.spring.initializr.metadata.Link +import org.junit.Test + +/** + * Tests for {@link LinkMapper}. + * + * @author Stephane Nicoll + */ +class LinkMapperTests { + + @Test + void mapSimpleRel() { + def links = new ArrayList() + links << new Link(rel: 'a', 'href': 'https://example.com', + description: 'some description') + def model = LinkMapper.mapLinks(links) + assert model.size() == 1 + assert model.containsKey('a') + def linkModel = model['a'] + assert linkModel.size() == 2 + assert linkModel['href'] == 'https://example.com' + assert linkModel['title'] == 'some description' + } + + @Test + void mapTemplatedRel() { + def links = new ArrayList() + links << new Link(rel: 'a', 'href': 'https://example.com/{bootVersion}/a', + templated: true) + def model = LinkMapper.mapLinks(links) + assert model.size() == 1 + assert model.containsKey('a') + def linkModel = model['a'] + assert linkModel.size() == 2 + assert linkModel['href'] == 'https://example.com/{bootVersion}/a' + assert linkModel['templated'] == true + } + + @Test + void mergeSeveralLinksInArray() { + def links = new ArrayList() + links << new Link(rel: 'a', 'href': 'https://example.com', + description: 'some description') + links << new Link(rel: 'a', 'href': 'https://example.com/2') + def model = LinkMapper.mapLinks(links) + assert model.size() == 1 + assert model.containsKey('a') + def linksModel = model['a'] + assert linksModel.size() == 2 + assert linksModel[0]['href'] == 'https://example.com' + assert linksModel[1]['href'] == 'https://example.com/2' + } + + @Test + void keepOrdering() { + def links = new ArrayList() + links << new Link(rel: 'a', 'href': 'https://example.com') + links << new Link(rel: 'b', 'href': 'https://example.com') + def model = LinkMapper.mapLinks(links) + def iterator = model.keySet().iterator() + assert ++iterator == 'a' + assert ++iterator == 'b' + } + + @Test + void keepOrderingWithMultipleUrlForSameRel() { + def links = new ArrayList() + links << new Link(rel: 'a', 'href': 'https://example.com') + links << new Link(rel: 'b', 'href': 'https://example.com') + links << new Link(rel: 'a', 'href': 'https://example.com') + def model = LinkMapper.mapLinks(links) + def iterator = model.keySet().iterator() + assert ++iterator == 'a' + assert ++iterator == 'b' + } + +} diff --git a/initializr-web/src/test/resources/metadata/config/test-default.json b/initializr-web/src/test/resources/metadata/config/test-default.json index 87e26f23..4df1a07c 100644 --- a/initializr-web/src/test/resources/metadata/config/test-default.json +++ b/initializr-web/src/test/resources/metadata/config/test-default.json @@ -131,7 +131,18 @@ "groupId": "org.springframework.boot", "id": "web", "name": "Web", - "scope": "compile" + "scope": "compile", + "links": [ + { + "rel": "guide", + "description": "Building a RESTful Web Service", + "href": "https://example.com/guide" + }, + { + "rel": "reference", + "href": "https://example.com/doc" + } + ] }, { "starter": true, @@ -164,7 +175,23 @@ "starter": true, "keywords": ["thefoo", "dafoo"], "scope": "compile", - "version": "1.3.5" + "version": "1.3.5", + "links": [ + { + "rel": "guide", + "href": "https://example.com/guide1" + }, + { + "rel": "reference", + "href": "https://example.com/{bootVersion}/doc", + "templated": true + }, + { + "rel": "guide", + "description": "Some guide for foo", + "href": "https://example.com/guide2" + } + ] }, { "starter": true, diff --git a/initializr-web/src/test/resources/metadata/test-default-2.1.0.json b/initializr-web/src/test/resources/metadata/test-default-2.1.0.json index a64a5e2a..65269f38 100644 --- a/initializr-web/src/test/resources/metadata/test-default-2.1.0.json +++ b/initializr-web/src/test/resources/metadata/test-default-2.1.0.json @@ -30,7 +30,16 @@ { "id": "web", "name": "Web", - "description": "Web dependency description" + "description": "Web dependency description", + "_links": { + "guide": { + "href": "https://example.com/guide", + "title": "Building a RESTful Web Service" + }, + "reference": { + "href": "https://example.com/doc" + } + } }, { "id": "security", @@ -47,7 +56,22 @@ "values": [ { "id": "org.acme:foo", - "name": "Foo" + "name": "Foo", + "_links": { + "guide": [ + { + "href": "https://example.com/guide1" + }, + { + "href": "https://example.com/guide2", + "title": "Some guide for foo" + } + ], + "reference": { + "href": "https://example.com/{bootVersion}/doc", + "templated": true + } + } }, { "id": "org.acme:bar",