Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MC-9714 Import Folders as JSON (no Models) #297

Draft
wants to merge 17 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mdm-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Controller: folder
| PUT | /api/folders/${id} | Action: update
| GET | /api/folders/${id} | Action: show
| GET | /api/folders/${id}/export | Action: export
| POST | /api/folders/${id}/import | Action: import

Controller: importer
| GET | /api/importer/parameters/${ns}?/${name}?/${version}? | Action: parameters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ class UrlMappings {
get "/export/$exporterNamespace/$exporterName/$exporterVersion"(controller: 'folder', action: 'exportFolder')
}

group '/folders', {
post "/import/$importerNamespace/$importerName/$importerVersion"(controller: 'folder', action: 'importFolder')
}

'/versionedFolders'(resources: 'versionedFolder', excludes: DEFAULT_EXCLUDES) {
'/folders'(resources: 'folder', excludes: DEFAULT_EXCLUDES)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,27 @@
*/
package uk.ac.ox.softeng.maurodatamapper.core.container

import uk.ac.ox.softeng.maurodatamapper.api.exception.ApiException
import uk.ac.ox.softeng.maurodatamapper.core.container.provider.importer.FolderImporterProviderService
import uk.ac.ox.softeng.maurodatamapper.core.container.provider.importer.parameter.FolderImporterProviderServiceParameters
import uk.ac.ox.softeng.maurodatamapper.core.controller.EditLoggingController
import uk.ac.ox.softeng.maurodatamapper.core.importer.ImporterService
import uk.ac.ox.softeng.maurodatamapper.core.model.CatalogueItem
import uk.ac.ox.softeng.maurodatamapper.core.provider.MauroDataMapperServiceProviderService
import uk.ac.ox.softeng.maurodatamapper.core.provider.exporter.ExporterProviderService
import uk.ac.ox.softeng.maurodatamapper.core.provider.importer.ImporterProviderService
import uk.ac.ox.softeng.maurodatamapper.core.rest.transport.search.SearchParams
import uk.ac.ox.softeng.maurodatamapper.core.search.SearchService
import uk.ac.ox.softeng.maurodatamapper.hibernate.search.PaginatedHibernateSearchResult
import uk.ac.ox.softeng.maurodatamapper.security.SecurityPolicyManagerService

import grails.gorm.transactions.Transactional
import grails.web.http.HttpHeaders
import grails.web.mime.MimeType
import groovy.util.logging.Slf4j
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.validation.Errors
import org.springframework.web.multipart.support.AbstractMultipartHttpServletRequest

import static org.springframework.http.HttpStatus.CREATED
import static org.springframework.http.HttpStatus.NO_CONTENT
Expand All @@ -41,6 +49,7 @@ class FolderController extends EditLoggingController<Folder> {
static responseFormats = ['json', 'xml']

MauroDataMapperServiceProviderService mauroDataMapperServiceProviderService
ImporterService importerService
FolderService folderService
SearchService mdmCoreSearchService
VersionedFolderService versionedFolderService
Expand Down Expand Up @@ -185,11 +194,54 @@ class FolderController extends EditLoggingController<Folder> {
log.info("Exporting Folder using ${exporter.displayName}")
ByteArrayOutputStream outputStream = exporter.exportDomain(currentUser, params.folderId)
if (!outputStream) return errorResponse(UNPROCESSABLE_ENTITY, 'Folder could not be exported')
log.info('Export complete')
log.info('Single Folder Export complete')

render(file: outputStream.toByteArray(), fileName: "${instance.label}.${exporter.fileExtension}", contentType: exporter.fileType)
}

@Transactional
def importFolder() throws ApiException {
FolderImporterProviderService importer =
mauroDataMapperServiceProviderService.findImporterProvider(params.importerNamespace, params.importerName, params.importerVersion) as FolderImporterProviderService
if (!importer) return notFound(ImporterProviderService, "${params.importerNamespace}:${params.importerName}:${params.importerVersion}")

FolderImporterProviderServiceParameters importerProviderServiceParameters = request.contentType.startsWith(MimeType.MULTIPART_FORM.name)
? importerService.extractImporterProviderServiceParameters(importer, request as AbstractMultipartHttpServletRequest)
: importerService.extractImporterProviderServiceParameters(importer, request)

Errors errors = importerService.validateParameters(importerProviderServiceParameters, importer.importerProviderServiceParametersClass)
if (errors.hasErrors()) {
transactionStatus.setRollbackOnly()
return respond(errors)
}

if (!currentUserSecurityPolicyManager.userCanCreateResourceId(resource, null, Folder, importerProviderServiceParameters.parentFolderId)) {
if (!currentUserSecurityPolicyManager.userCanReadSecuredResourceId(Folder, importerProviderServiceParameters.parentFolderId)) {
return notFound(Folder, importerProviderServiceParameters.parentFolderId)
}
return forbiddenDueToPermissions()
}

Folder folder = importer.importDomain(currentUser, importerProviderServiceParameters) as Folder
if (!folder) {
transactionStatus.setRollbackOnly()
return errorResponse(UNPROCESSABLE_ENTITY, 'No folder imported')
}

folder.parentFolder = folderService.get(importerProviderServiceParameters.parentFolderId)
folderService.validate(folder)
if (folder.hasErrors()) {
transactionStatus.setRollbackOnly()
return respond(folder.errors)
}

log.debug('No errors in imported folder')
log.info('Single Folder Import complete')

saveResource(folder)
saveResponse(folder)
}

@Override
protected Folder queryForResource(Serializable id) {
folderService.get(id)
Expand Down Expand Up @@ -239,7 +291,6 @@ class FolderController extends EditLoggingController<Folder> {
namespace: hasProperty('namespace') ? this.namespace : null))
respond instance, [status: OK, view: 'update', model: [userSecurityPolicyManager: currentUserSecurityPolicyManager, folder: instance]]
}

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ class FolderInterceptor extends SecurableResourceInterceptor {
return true
}

if (actionName == 'importFolder') {
if (!currentUserSecurityPolicyManager.userCanEditSecuredResourceId(Folder, id)) {
return forbiddenOrNotFound(false, Folder, id)
}
return true
}

checkActionAuthorisationOnSecuredResource(Folder, getId(), true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,6 @@ class FolderService extends ContainerService<Folder> {
throw new ApiNotYetImplementedException('MSXX', 'Folder permission copying')
}
log.warn('Permission copying is not yet implemented')

}
log.debug('Validating and saving copy')
setFolderRefinesFolder(copiedFolder, original, copier)
Expand Down Expand Up @@ -645,4 +644,13 @@ class FolderService extends ContainerService<Folder> {
if (parentCache) parentCache.addDiffCache(folder.path, fDiffCache)
fDiffCache
}

void checkImportedFolderAssociations(User importingUser, Folder folder) {
folder.checkPath()
folder.createdBy = importingUser.emailAddress
folder.validate()
checkFacetsAfterImportingMultiFacetAware(folder)
folder.childFolders?.each { checkImportedFolderAssociations(importingUser, it) }
log.debug('Folder associations checked')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2020 University of Oxford and Health and Social Care Information Centre, also known as NHS Digital
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
*/
package uk.ac.ox.softeng.maurodatamapper.core.container.provider.importer

import uk.ac.ox.softeng.maurodatamapper.api.exception.ApiBadRequestException
import uk.ac.ox.softeng.maurodatamapper.core.container.Folder
import uk.ac.ox.softeng.maurodatamapper.core.container.provider.importer.parameter.FolderFileImporterProviderServiceParameters
import uk.ac.ox.softeng.maurodatamapper.core.traits.provider.importer.JsonImportMapping
import uk.ac.ox.softeng.maurodatamapper.security.User

import groovy.util.logging.Slf4j

@Slf4j
class FolderJsonImporterService extends DataBindFolderImporterProviderService<FolderFileImporterProviderServiceParameters> implements JsonImportMapping {

@Override
String getDisplayName() {
'JSON Folder Importer'
}

@Override
String getVersion() {
'1.0'
}

@Override
Boolean canImportMultipleDomains() {
false
}

@Override
Folder importFolder(User currentUser, byte[] content) {
log.debug('Parsing in file content using JsonSlurper')
Map folder = slurpAndClean(content).folder
if (!folder) throw new ApiBadRequestException('JIS03', 'Cannot import JSON as folder is not present')

log.debug('Importing Folder map')
bindMapToFolder(currentUser, new HashMap(folder))
}

@Override
List<Folder> importFolders(User currentUser, byte[] content) {
throw new ApiBadRequestException('FBIP04', "${name} cannot import multiple Folders")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ import uk.ac.ox.softeng.maurodatamapper.test.functional.ResourceFunctionalSpec
import grails.gorm.transactions.Rollback
import grails.testing.mixin.integration.Integration
import grails.testing.spock.RunOnce
import grails.web.mime.MimeType
import groovy.util.logging.Slf4j
import io.micronaut.core.type.Argument
import io.micronaut.http.HttpStatus
import spock.lang.Shared

import static uk.ac.ox.softeng.maurodatamapper.core.bootstrap.StandardEmailAddress.getFUNCTIONAL_TEST

/**
* <pre>
Expand All @@ -42,17 +46,25 @@ import io.micronaut.http.HttpStatus
* | POST | /api/folders/${folderId}/search | Action: search
*
* | GET | /api/folders/${folderId}/export | Action: export
* | POST | /api/folders/${folderId}/import | Action: import
* </pre>
* @see uk.ac.ox.softeng.maurodatamapper.core.container.FolderController
*/
@Integration
@Slf4j
class FolderFunctionalSpec extends ResourceFunctionalSpec<Folder> {

@Shared
UUID parentFolderId

@RunOnce
@Rollback
def setup() {
assert Folder.count() == 0
log.debug('Check and setup test data')
sessionFactory.currentSession.flush()
parentFolderId = new Folder(label: 'Parent Functional Test Folder', createdBy: FUNCTIONAL_TEST).save(flush: true).id
assert parentFolderId
assert Folder.count() == 1
}

@Override
Expand Down Expand Up @@ -94,6 +106,14 @@ class FolderFunctionalSpec extends ResourceFunctionalSpec<Folder> {
}'''
}

@Override
void verifyR1EmptyIndexResponse() {
verifyResponse(HttpStatus.OK, response)
assert response.body().count == 1
assert response.body().items.size() == 1
assert response.body().items[0].label == 'Parent Functional Test Folder'
}

void 'Test the save action fails when using the same label persists an instance'() {
given:
List<String> createdIds = []
Expand Down Expand Up @@ -544,4 +564,120 @@ class FolderFunctionalSpec extends ResourceFunctionalSpec<Folder> {
cleanup:
ids.reverseEach { cleanUpData(it) }
}

void 'FE06 : test import Folder JSON'() {
when: 'The save action is executed with valid data'
POST('', validJson)

then: 'The response is correct'
response.status == HttpStatus.CREATED
response.body().id

when: 'The export action is executed with a valid instance'
String id = response.body().id
GET("${id}/export/uk.ac.ox.softeng.maurodatamapper.core.container.provider.exporter/FolderJsonExporterService/1.0", STRING_ARG)

then: 'The response is correct'
verifyResponse(HttpStatus.OK, jsonCapableResponse)

when: 'The delete action is executed with a valid instance'
String exportedJson = jsonCapableResponse.body()
DELETE(getDeleteEndpoint(id))

then: 'The response is correct'
response.status == HttpStatus.NO_CONTENT

when: 'The import action is executed with valid data'
POST('import/uk.ac.ox.softeng.maurodatamapper.core.container.provider.importer/FolderJsonImporterService/1.0', [
parentFolderId: parentFolderId.toString(),
importFile : [
fileName : 'FT Import',
fileType : MimeType.JSON_API.name,
fileContents: exportedJson.bytes.toList()
]
], STRING_ARG)

then: 'The response is correct'
verifyJsonResponse(HttpStatus.CREATED, '''{
"id": "${json-unit.matches:id}",
"label": "Functional Test Folder",
"lastUpdated": "${json-unit.matches:offsetDateTime}",
"domainType": "Folder",
"hasChildFolders": false,
"readableByEveryone": false,
"readableByAuthenticatedUsers": false,
"availableActions": [
"delete",
"show",
"update"
]
}''')
}

void 'FE07 : test import Folder with child Folders JSON'() {
given:
List<HttpStatus> responseStatuses = []
List<String> ids = []
Closure<Void> saveResponse = { ->
responseStatuses << response.status
ids << response.body()?.id
}

when: 'The save actions are executed with valid data'
POST('', validJson)
saveResponse()
POST("${ids[0]}/folders", [label: 'Functional Test Folder 2'])
saveResponse()
POST("${ids[0]}/folders", [label: 'Functional Test Folder 3'])
saveResponse()
POST("${ids[2]}/folders", [label: 'Functional Test Folder 4'])
saveResponse()

then: 'The responses are correct'
responseStatuses.every { it == HttpStatus.CREATED }
ids.every()

when: 'The export action is executed with a valid instance'
GET("${ids[0]}/export/uk.ac.ox.softeng.maurodatamapper.core.container.provider.exporter/FolderJsonExporterService/1.0", STRING_ARG)

then: 'The response is correct'
verifyResponse(HttpStatus.OK, jsonCapableResponse)

when: 'The delete action is executed with a valid instance'
String exportedJson = jsonCapableResponse.body()
responseStatuses.clear()
ids.reverseEach {
DELETE(getDeleteEndpoint(it))
responseStatuses << response.status
}

then: 'The response is correct'
responseStatuses.every { it == HttpStatus.NO_CONTENT }

when: 'The import action is executed with valid data'
POST('import/uk.ac.ox.softeng.maurodatamapper.core.container.provider.importer/FolderJsonImporterService/1.0', [
parentFolderId: parentFolderId.toString(),
importFile : [
fileName : 'FT Import',
fileType : MimeType.JSON_API.name,
fileContents: exportedJson.bytes.toList()
]
], STRING_ARG)

then: 'The response is correct'
verifyJsonResponse(HttpStatus.CREATED, '''{
"id": "${json-unit.matches:id}",
"label": "Functional Test Folder",
"lastUpdated": "${json-unit.matches:offsetDateTime}",
"domainType": "Folder",
"hasChildFolders": true,
"readableByEveryone": false,
"readableByAuthenticatedUsers": false,
"availableActions": [
"delete",
"show",
"update"
]
}''')
}
}
Loading