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",