feat(manager-api): deep scan objects for `@BlobReference`
Deep scan objects for `@BlobReference` and return blob reference info from endpoints, in addition to
the `Location` header.
MODIFY
manager/api/beans/src/main/java/io/apiman/manager/api/beans/apis/UpdateApiBean.java
manager/api/beans/src/main/java/io/apiman/manager/api/beans/apis/UpdateApiBean.java
@@ -37,8 +37,7 @@ public class UpdateApiBean implements Serializable {
private static final long serialVersionUID = 8811488441452291116L;
private String description;
- @JsonIgnore
- @BlobReference
+ // The image reference
private String image;
private Set<KeyValueTagDto> tags;

MODIFY
manager/api/exceptions/pom.xml
manager/api/exceptions/pom.xml
<groupId>io.apiman</groupId>
<artifactId>apiman-common-plugin</artifactId>
</dependency>
-
+ <dependency>
+ <groupId>io.apiman</groupId>
+ <artifactId>apiman-common-logging-core</artifactId>
+ </dependency>
<!-- Third Party Dependencies -->
<dependency>
<groupId>javax.enterprise</groupId>

MODIFY
manager/api/exceptions/src/main/java/io/apiman/manager/api/rest/exceptions/mappers/IllegalArgumentExceptionMapper.java
manager/api/exceptions/src/main/java/io/apiman/manager/api/rest/exceptions/mappers/IllegalArgumentExceptionMapper.java
package io.apiman.manager.api.rest.exceptions.mappers;
+import io.apiman.common.logging.ApimanLoggerFactory;
+import io.apiman.common.logging.IApimanLogger;
import io.apiman.manager.api.beans.exceptions.ErrorBean;
import javax.enterprise.context.ApplicationScoped;
@@ -16,6 +18,7 @@ import javax.ws.rs.ext.Provider;
@Provider
@ApplicationScoped
public class IllegalArgumentExceptionMapper implements ExceptionMapper<IllegalArgumentException> {
+ private final IApimanLogger log = ApimanLoggerFactory.getLogger(IllegalArgumentExceptionMapper.class);
public IllegalArgumentExceptionMapper() {
}
@@ -31,6 +34,7 @@ public class IllegalArgumentExceptionMapper implements ExceptionMapper<IllegalAr
ResponseBuilder builder = Response.status(Status.BAD_REQUEST)
.header("X-Apiman-Error", "true");
builder.type(MediaType.APPLICATION_JSON_TYPE);
+ log.error(data, data.getMessage());
return builder.entity(error).build();
}

MODIFY
manager/api/jpa/src/main/java/io/apiman/manager/api/jpa/blobstore/BlobStoreRepository.java
manager/api/jpa/src/main/java/io/apiman/manager/api/jpa/blobstore/BlobStoreRepository.java
@@ -28,8 +28,7 @@ public class BlobStoreRepository extends AbstractJpaStorage {
* UUID recommended as a component of ID to avoid collisions.
*/
public void create(@NotNull BlobEntity bean) throws StorageException {
- Preconditions.requireNonBlank(bean.getId(),
- "Caller must set ID for blob before storing to ensure they are able to look it up");
+ Preconditions.requireNonBlank(bean.getId(), "Caller must set ID for blob before storing to ensure they are able to look it up");
super.create(bean);
}
@@ -50,6 +49,7 @@ public class BlobStoreRepository extends AbstractJpaStorage {
public void deleteById(@NotNull String uid) throws StorageException {
Preconditions.requireNonBlank(uid, "uid must be non-blank");
EntityManager em = super.getActiveEntityManager();
+ em.flush();
em.createQuery("DELETE "
+ "FROM BlobEntity b "
+ "WHERE b.references <= 1 "

MODIFY
manager/api/jpa/src/main/java/io/apiman/manager/api/jpa/blobstore/SqlBlobStoreService.java
manager/api/jpa/src/main/java/io/apiman/manager/api/jpa/blobstore/SqlBlobStoreService.java
@@ -95,6 +95,7 @@ public class SqlBlobStoreService implements IBlobStore {
.setName(name)
.setMimeType(mimeType)
.setBlob(BlobProxy.generateProxy(fbos.asByteSource().openStream(), fbos.asByteSource().size()))
+ .setReferences(initRefCount)
.setHash(hash);
// Returned ID might be different to the one we generated if we found a duplicate.
return toBlobRef(deduplicateOrStore(name, mimeType, hash, blobEntity));
@@ -126,6 +127,7 @@ public class SqlBlobStoreService implements IBlobStore {
.setName(name)
.setMimeType(mimeType)
.setBlob(BlobProxy.generateProxy(bytes))
+ .setReferences(initRefCount)
.setHash(hash);
try {
// Returned ID might be different to the one we generated if we found a duplicate.

MODIFY
manager/api/rest-impl/src/main/java/io/apiman/manager/api/rest/impl/BlobResourceImpl.java
manager/api/rest-impl/src/main/java/io/apiman/manager/api/rest/impl/BlobResourceImpl.java
@@ -92,7 +92,10 @@ public class BlobResourceImpl implements IBlobResource {
BlobRef blobRef = blobStore.storeBlob(filename, image.getMediaType().toString(), image.getFileBackedOutputStream(), 0);
// Where the blob can be resolved
URI location = UriBuilder.fromResource(IBlobResource.class).path(blobRef.getId()).build();
- return Response.created(location).build();
+ return Response
+ .created(location)
+ .entity(blobRef)
+ .build();
}
private boolean checkExtension(String ext) {

MODIFY
manager/api/rest-impl/src/main/java/io/apiman/manager/api/rest/impl/util/MultipartHelper.java
manager/api/rest-impl/src/main/java/io/apiman/manager/api/rest/impl/util/MultipartHelper.java
@@ -73,7 +73,7 @@ public class MultipartHelper {
}
}
// If blob is very small, may be an error or DOS/spam
- RestArgumentVerifier.checkArgument(bytesTransferred < 10, "Provided file too small");
+ RestArgumentVerifier.checkArgument(bytesTransferred > 10, "Provided file too small");
} catch (IOException ioe) {
IOUtils.closeQuietly(os);
throw new UncheckedIOException(ioe);

MODIFY
manager/api/rest-impl/src/main/java/io/apiman/manager/api/rest/interceptors/BlobResourceInterceptorProvider.java
manager/api/rest-impl/src/main/java/io/apiman/manager/api/rest/interceptors/BlobResourceInterceptorProvider.java
@@ -7,8 +7,12 @@ import io.apiman.manager.api.rest.IBlobResource;
import java.io.IOException;
import java.lang.reflect.Field;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashSet;
import java.util.List;
-import java.util.stream.Collectors;
+import java.util.Set;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.ext.Provider;
@@ -61,23 +65,75 @@ public class BlobResourceInterceptorProvider implements WriterInterceptor {
}
private void rewrite(WriterInterceptorContext context) throws IllegalAccessException {
- Object entity = context.getEntity();
- List<Field> blobRefs = ReflectionUtils.getAllFields(entity.getClass(), f -> f.isAnnotationPresent(BlobReference.class))
- .stream()
- .filter(f -> f.getType().equals(String.class))
- .collect(Collectors.toList());
- for (Field blobRef : blobRefs) {
- blobRef.setAccessible(true);
+ Object rootEntity = context.getEntity();
+ List<FieldAndEntity> blobRefs = searchRecursively(rootEntity);
+
+ for (FieldAndEntity fieldAndEntity : blobRefs) {
+ Object entity = fieldAndEntity.entity;
+ Field blobRef = fieldAndEntity.field;
String existingValue = (String) blobRef.get(entity);
if (existingValue == null || existingValue.isBlank()) {
LOGGER.debug("Null or blank @BlobRef {0}@{1}", blobRef.getName(), entity.getClass().getCanonicalName());
} else {
- String resolvedRef = UriBuilder.fromResource(IBlobResource.class).path(existingValue).build()
+ String resolvedRef = UriBuilder.fromResource(IBlobResource.class)
+ .path(existingValue)
+ .build()
.toString();
- LOGGER.debug("Rewriting response POJO field annotated with resolved @BlobReference: {0} -> {1}",
- existingValue, resolvedRef);
+ LOGGER.debug("Rewriting response POJO field annotated with resolved @BlobReference: {0} -> {1}", existingValue, resolvedRef);
blobRef.set(entity, resolvedRef);
}
}
}
+
+ /**
+ * Chase object graph for all @BlobReference strings.
+ * <ul>
+ * <li>Starting with root and until no entries remain: pop entry off stack, this becomes <code>currentNode</code></li>
+ * <li>Iterate through every field in <code>currentNode</code>.</li>
+ * <li>If a field is a <code>String</code> and annotated with <code>BlobReference</code>, it is captured.</li>
+ * <li>Any non-String type that begins with <code>io.apiman</code> is pushed onto the stack. All other types are ignored.</li>
+ * </ul>
+ * TODO(msavy): Add cache or use Jandex to avoid repeated expensive reflection and iteration.
+ * Simple!
+ */
+ @SuppressWarnings("unchecked")
+ private List<FieldAndEntity> searchRecursively(Object root) throws IllegalAccessException {
+ List<FieldAndEntity> results = new ArrayList<>();
+ Set<Object> objectsSeen = new HashSet<>();
+ Deque<Object> nodesToSearch = new ArrayDeque<>();
+ nodesToSearch.push(root);
+ while (!nodesToSearch.isEmpty()) {
+ Object currentNode = nodesToSearch.pop();
+ for (Field f : ReflectionUtils.getAllFields(currentNode.getClass())) {
+ f.setAccessible(true);
+ if (f.isAnnotationPresent(BlobReference.class) && f.getType().equals(String.class)) {
+ results.add(new FieldAndEntity(f, currentNode));
+ } else {
+ if (!f.getType().isEnum()
+ // Is an Apiman entity.
+ && f.getType().getCanonicalName().startsWith("io.apiman")
+ // Must not have seen this before (i.e. simple cycle detection).
+ && !objectsSeen.contains(currentNode)) {
+ Object value = f.get(currentNode);
+ if (value != null) {
+ nodesToSearch.push(value);
+ objectsSeen.add(value);
+ }
+ }
+ }
}
+ }
+ return results;
+ }
+
+ private static final class FieldAndEntity {
+ public Field field;
+ public Object entity;
+
+ public FieldAndEntity(Field field, Object entity) {
+ this.field = field;
+ this.entity = entity;
+ }
+ }
+}
+

MODIFY
manager/api/rest-impl/src/main/java/io/apiman/manager/api/service/ApiService.java
manager/api/rest-impl/src/main/java/io/apiman/manager/api/service/ApiService.java
@@ -93,7 +93,6 @@ import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
-import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
@@ -283,7 +282,15 @@ public class ApiService implements DataAccessUtilMixin {
}
if (AuditUtils.valueChanged(apiForUpdate.getImage(), bean.getImage())) {
auditData.addChange("image", apiForUpdate.getImage(), bean.getImage());
+ // Remove old image
+ if (apiForUpdate.getImage() != null) {
+ blobStore.remove(apiForUpdate.getImage());
+ }
+ // Attach to new image
apiForUpdate.setImage(bean.getImage());
+ if (bean.getImage() != null) {
+ blobStore.attachToBlob(bean.getImage());
+ }
}
if (AuditUtils.valueChanged(tagMapper.toDto(apiForUpdate.getTags()), bean.getTags())) {
// TODO(msavy): add audit entry.

MODIFY
manager/api/rest-impl/src/main/java/io/apiman/manager/api/service/ClientAppService.java
manager/api/rest-impl/src/main/java/io/apiman/manager/api/service/ClientAppService.java
@@ -200,10 +200,17 @@ public class ClientAppService implements DataAccessUtilMixin {
}
if (AuditUtils.valueChanged(clientForUpdate.getImage(), bean.getImage())) {
auditData.addChange("image", clientForUpdate.getImage(), bean.getImage()); //$NON-NLS-1$
- clientForUpdate.setImage(bean.getImage());
// As an upload will have happened separately, we need to attach to the blob so that it doesn't get wiped out later.
+ // Remove old image
+ if (clientForUpdate.getImage() != null) {
+ blobstore.remove(clientForUpdate.getImage());
+ }
+ // Attach to new image
+ clientForUpdate.setImage(bean.getImage());
+ if (bean.getImage() != null) {
blobstore.attachToBlob(bean.getImage());
}
+ }
storage.updateClient(clientForUpdate);
storage.createAuditEntry(AuditUtils.clientUpdated(clientForUpdate, auditData, securityContext));

MODIFY
manager/ui/war/plugins/api-manager/ts/api/api-devportal.ts
manager/ui/war/plugins/api-manager/ts/api/api-devportal.ts
@@ -13,6 +13,7 @@ import { BlobRef } from "../model/blob.model";
import Cropper from "cropperjs/dist/cropper.esm";
import "cropperjs/dist/cropper.css";
import angular = require("angular");
+import { remove as _remove } from "lodash-es";
// Use CommonJS syntax for tui stuff
const toast = require("@toast-ui/editor");
const codeSyntaxHighlight = require("@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all");
@@ -79,9 +80,16 @@ _module.controller("Apiman.DevPortalController", [
// $scope.versions = ApiSummaryBean[]
// $scope.version = ApiVersionBean
+ // Get the API Version Plan summaries
+ DevPortalService.getApiVersionPlans(
+ params.org,
+ params.api,
+ params.version
+ ).then((apiPlans: ApiPlanSummaryBean[]) => {
+ Logger.info("Got plan summaries: {0}", apiPlans);
- devPortalBuisnessLogic(
+ devPortalBusinessLogic(
pageData,
$q,
$scope,
@@ -99,15 +107,17 @@ _module.controller("Apiman.DevPortalController", [
Logger,
$interval,
$uibModal,
- params
+ params,
+ apiPlans
);
+ });
}
);
},
]);
-function devPortalBuisnessLogic(
+function devPortalBusinessLogic(
pageData,
$q,
$scope,
@@ -125,20 +135,22 @@ function devPortalBuisnessLogic(
Logger,
$interval,
$uibModal,
- params
+ params,
+ apiPlans: ApiPlanSummaryBean[]
) {
- let dataClone: any; // This will be set after summaries have loaded.
-
$rootScope.isDirty = false;
/** Data **/
$scope.data = {
apiVersion: $scope.version as ApiVersionBean, // $scope.version is bound magically by PageLifecycle... Maybe pass in as an arg.
- planSummaries: [] as ApiPlanSummaryBean[], // Will be loaded further down
+ planSummaries: apiPlans,
isFeaturedApi: isFeaturedApi($scope.version.api),
- latestImage: $scope.version.api.image
+ latestImage: `${Configuration.api.endpoint}/${$scope.version.api.image}`
}
+ // Original clean copy.
+ const dataClone = angular.copy($scope.data);
+
/** Functions **/
$scope.updateFeaturedApi = invertFeaturedApi;
$scope.openImageCropper = openImageCropperModal;
@@ -148,23 +160,11 @@ function devPortalBuisnessLogic(
/** Start biz logic **/
- // Get the API Version Plan summaries
- DevPortalService.getApiVersionPlans(
- params.org,
- params.api,
- params.version
- ).then((apiPlans: ApiPlanSummaryBean[]) => {
- Logger.info("Got plans: {0}", apiPlans);
- $scope.data.planSummaries.push(...apiPlans);
- Logger.info("Cloning pristine $scope.data -> dataClone (for restore/reset).")
- dataClone = angular.copy($scope.data);
- });
-
/**
* Watch `data.apiVersion` for changes by doing a deep comparison
*/
$scope.$watch(
- "data.apiVersion",
+ "data",
(oldValue, newValue) => {
if (!angular.equals(oldValue, newValue)) {
Logger.debug("Dirty set to true {0} vs {1}", oldValue, newValue);
@@ -181,7 +181,7 @@ function devPortalBuisnessLogic(
true
);
- /*** Markdown Editor ***/
+ /** Markdown Editor **/
function initEditor(): void {
const options: EditorOptions = {
el: document.querySelector("#editor"),
@@ -232,9 +232,7 @@ function devPortalBuisnessLogic(
$scope.data.apiVersion.extendedDescription = latestDescription;
}
};
-
- /*** End Markdown Editor ***/
-
+ /** End Markdown Editor **/
/** Save, and reset **/
// Reset (copy saved pristine copy back over).
@@ -305,8 +303,7 @@ function devPortalBuisnessLogic(
function openImageCropperModal(): void {
const modalInstance = $uibModal.open({
animation: true,
- templateUrl:
- "plugins/api-manager/html/api/api-devportal-cropper-modal.html",
+ templateUrl: "plugins/api-manager/html/api/api-devportal-cropper-modal.html",
size: "md",
controller: "Apiman.DevPortalImageCropper",
});
@@ -314,6 +311,7 @@ function devPortalBuisnessLogic(
modalInstance.result.then(
(bmr: BlobModalReturn) => {
$scope.data.latestImage = bmr.croppedCanvas.toDataURL(bmr.type, 100);
+ console.log("$scope.data.apiVersion.api.image=" + bmr.blobRef.id);
$scope.data.apiVersion.api.image = bmr.blobRef.id;
},
(dismissed) => {}
@@ -326,11 +324,9 @@ function devPortalBuisnessLogic(
const tagsArray: KeyValueTagDto[] = api.tags;
// If featured remove featured entry, and vince versa...
if (isFeatured) {
- tagsArray.forEach((candidate: KeyValueTagDto, index) => {
- if (candidate.key === "featured") {
- tagsArray.splice(index, 1);
- }
- });
+ _remove(tagsArray, (candidate) => {
+ return candidate.key === "featured";
+ })
} else {
tagsArray.push({ key: "featured" } as KeyValueTagDto);
}

feat(manager-api): deep scan objects for `@BlobReference` 

Deep scan objects for `@BlobReference` and return blob reference info from endpoints, in addition to
the `Location` header.