fi.linuxbox.upcloud.core.Resource.groovy Maven / Gradle / Ivy
Show all versions of groovy-upcloud-core Show documentation
package fi.linuxbox.upcloud.core
import groovy.transform.PackageScope
import org.slf4j.*
/**
* Model for a resource in UpCloud, e.g. server, storage device, or an IP address.
*
*
* Together with the {@link API} class, this class is the most core of the Groovy UpCloud library. You probably don't
* use this directly, but you could. This class allows for dynamic creation of UpCloud resource representations and
* their properties.
*
*
*
* For example, at the time of this writing, the UpCloud account API returns credits
and
* username
properties, but no email
property. Not saying that it should, but it could be
* useful. Now, if a future revision of the API adds that property, it would be immediately available in the
* Account
resource of this library, even without an update.
*
*
* HTTP/1.1 200 OK
* Content-Type: application/json; charset=UTF-8
*
* {
* "account": {
* "credits": 1000,
* "username": "foo_bar",
* "email": "[email protected]"
* }
* }
*
*
* upcloud.account({ response ->
* assert response.account.email == "[email protected]"
* })
*
*
*
* Similarly, if a future revision of the UpCloud API would add a completely new property to, say, a server, represented
* as a new kind of object, then it would be visible in the responses generated by this library. For example:
*
*
* HTTP/1.1 200 OK
* Content-Type: application/json; charset=UTF-8
*
* {
* "server": {
* ...
* "docker_images": {
* "docker_image": [
* {
* "image": "ubuntu:latest",
* "state": "running"
* },
* {
* "image": "nginx:latest",
* "state": "running"
* }
* ]
* },
* ...
* }
* }
*
*
*
* Above, the docker_images
property is an imaginary one, but because the server returned it, it would be
* available in the application callback as follows:
*
*
*
* server.load({ response ->
* assert response.server instanceof fi.linuxbox.upcloud.resource.Server
* // Above is old news.
* // Now for the fun part:
* assert response.server.dockerImages instanceof List
* assert response.server.dockerImages.every { it instanceof fi.linuxbox.upcloud.resource.DockerImage }
* assert response.server.dockerImages[0].state == "running"
* })
*
*
*
* In reality, you wouldn't be able to write that code exactly as shown, because the DockerImage
class is
* not available at compile time, i.e. your compiler would complain about the reference. But at runtime the class is
* as real as any other resource class.
*
*
*
* THE FOLLOWING IS JUST UNFINISHED NOTES
*
*
*
* Representation is always a Map with String keys. The value can be
*
*
*
* - A list wrapper: a map with one key whose value is a list
* - A resource: a map that is not a list wrapper
* - A simple value: anything except a map
*
*
*
* JSON data types:
*
*
*
* - object: either a list wrapper or a resource
* - array, number, string, boolean, null: a simple value
*
*/
class Resource {
private final Logger log = LoggerFactory.getLogger(Resource)
private static final GroovyClassLoader gcl = new GroovyClassLoader(Resource.classLoader)
private static final String resourcePackageName = "fi.linuxbox.upcloud.resource"
final API API
final META META
/**
* Designated, and only, constructor.
*
* @param kwargs.API The API instance. This is used by the resource specific API wrappers, not directly by this class.
* @param kwargs.META The META instance. This is received from the HTTP implementation.
* @param kwargs.repr The Map intermediary representation from the JSON implementations.
*/
Resource(final Map kwargs = [ :]) {
// the second arg (register=false) makes this metaClass instance scoped
metaClass = new ExpandoMetaClass(this.class, false, true)
metaClass.initialize()
API = kwargs.API
META = kwargs.META
final Map map = kwargs.remove('repr') as Map
map?.each { final String key, final Object value ->
if (value instanceof Map) {
if (isListWrapper(value)) {
// value ----v
// 'objects': [ 'object': [ [:], ... ]
// 'objects': [ 'object': [ string, ... ]
final String type_name = value.keySet()[0] // the only key
final String className = className(type_name)
final String propertyName = propertyName(key)
// e.g.
// [
// prices: [
// zone: [
// [:],
// ...
// ]
// ]
// ]
// becomes
// this.prices = [ Zone(), ... ]
final List list = (List) value[type_name];
this.metaClass."$propertyName" = list.collect { element ->
if (element instanceof Map) {
final Class clazz = loadResourceClass(className)
element = clazz.metaClass.invokeConstructor([repr: element] + kwargs)
}
element
}
//__setter_getter(this, attr)
} else { // Map but not list wrapper
// 'object': [key1: *, key2: *, ...]
final String className = className(key)
final String propertyName = propertyName(key)
final Class clazz = loadResourceClass(className)
this.metaClass."$propertyName" = clazz.metaClass.invokeConstructor(kwargs + [repr: value])
//__setter_getter(this, attr)
}
} else { // not Map
// simple attribute
final String propertyName = propertyName(key)
this.metaClass."$propertyName" = value
//__setter_getter(this, attr)
}
}
}
/**
* Returns properties of this resource.
*
*
* This method skips GroovyObject properties (every Groovy object has a 'class' property), meta resource properties
* (every Resource has 'META' and 'API' properties), and properties whose value is null
.
*
*
* @return Properties of this resource.
*/
private List> resourceProperties() {
this.properties.grep { final Map.Entry property ->
if (property.value == null)
null
else if (property.key == 'class' && property.value instanceof Class)
null
else if (property.key =~ /^[A-Z]+$/)
null
else
property
}
}
/**
* Converts this resource to a Map representation for JSON generation.
*
*
* This is used as resource as Map
and meant to be invoked only from the API class, just before the resource
* is put into the queue of to-be-sent requests.
*
*
*
* Instead of using direct object-to-JSON mapping, this intermediary representation is used because
*
*
* -
* This allows for a quick snapshot of the resource. Generating the JSON could happen in a background thread.
*
* -
* This allows the JSON generator to be stupid. It doesn't have to deal with conversion between Java and
* JSON style of property names. Also, it doesn't have to know how to filter the properties, or how to
* introspect for them in the first place.
*
* -
* This allows the JSON to be changed to Protobuf or XML more easily. Or the JSON implementation to change
* mode easily. This is because the requirements for those modules is very low.
*
*
*
* @param clazz Map. Nothing else supported.
* @return Representation of this resource.
*/
Object asType(Class clazz) {
resourceProperties().grep { final Map.Entry property ->
if (property.value instanceof List && property.value.isEmpty())
null
else
property
}.inject([:]) { final Map map, final Map.Entry property ->
final String property_name = type_name(property.key)
map[property_name] = property.value.with {
if (it instanceof List) {
final Object firstElement = it[0]
if (firstElement instanceof Resource) {
final String type_name = type_name(firstElement.class.simpleName)
// this.prices = [ Zone(), ... ]
// map[prices] = [ zone: [ [:], ... ] ]
// this.timezones = [ "asd", "dsa", ... ]
// map[timezones] = [ timezone: [ "asd", "dsa", ... ] ]
// the parens for the key force the GString to evaluate to String
[ (type_name): it.collect { it as Map }]
} else if (firstElement instanceof String) {
// HACK: only places I know of where we need this:
// * server creation: [ssh_keys: [ssh_key: [key1, key2]]]
// * tag creation: [servers: [server: [uuid1, uuid2]]]
// * tag update: [servers: [server: [uuid1, uuid2]]]
// so naive unpluralization of the property_name (ssh_keys or servers)
// will do for those.
final String type_name = property_name.substring(0, property_name.size() - 1)
[ (type_name): it.collect { it as String }]
} else {
throw new UnsupportedOperationException("Only lists of Resources or Strings is currently supported!!!")
}
} else if (it instanceof Resource) {
// this.error = Error()
it as Map
} else {
// this.id = "fi-hel1"
it
}
}
map
}
}
/**
* Returns this resource wrapped in another resource.
*
*
* Many of the UpCloud APIs require a wrapping JSON object to be sent into the resources, and this method is used
* in those places.
*
*
* @return A wrapped resource whose sole property is this resource.
*/
def wrapper() {
final String propertyName = propertyName(this.class)
new Resource(API: API, META: META)."$propertyName"(this)
}
/**
* Returns a projection of this resource.
*
*
* Projection is a copy of this resource with some of the properties removed.
*
*
*
* Some of the UpCloud APIs specifically disallow some of the resource properties from being sent in the requests,
* and this method is used in those API calls.
*
*
* @param properties A list of property names to exclude.
* @return A copy of this resource with specified properties removed.
*/
def proj(final List properties) {
resourceProperties().grep { final Map.Entry property ->
if (property.key in properties)
property
else
null
}.inject ((Resource)this.metaClass.invokeConstructor(API: API, META: META)) {
final Resource resource, final Map.Entry property -> resource."${property.key}"(property.value)
}
}
/**
* MOP method that allows resource properties to be set in a fluent fashion.
*
*
* Allows for code like server.name('my server').description('My server')
to set two properties for
* the server resource.
*
*
* @param name Name of the property.
* @param args Value of the property (must be a single element argument array).
* @return The resource for chaining.
*/
def methodMissing(final String name, final def args) {
if (args?.length == 1)
this."$name" = args[0]
this
}
/**
* MOP method that allows resource properties to be missing.
*
*
* Allows for code like if (server.name) ...
without actually defining Java Bean property for the name.
*
*
* @param name Name of the property.
* @return Value of the property or null
.
*/
def propertyMissing(final String name) {
// Help the trait proxies see all the properties (we used to return null here, but the proxies didn't work).
this.properties[name]
}
/**
* MOP method that allows resource properties to be set.
*
*
* Allows for code like server.name = "my server"
without actually defining Java Bean property for the
* name.
*
*
* @param name Name of the property.
* @param arg Value of the property.
* @return Value of the property.
*/
def propertyMissing(final String name, final def arg) {
this.metaClass."$name" = arg
}
/**
* Converts Class name to a Java style property name.
*
* @param clazz Simple class name, i.e. name without the package.
* @return Java style property name.
*/
@PackageScope
static String propertyName(final Class clazz) {
clazz.simpleName.replaceAll(/([A-Z])([A-Z]+)/, { it[1] + it[2].toLowerCase() }) // RESOURCE -> Resource
.replaceFirst(/^([A-Z])/, { it[0].toLowerCase() }) // Server -> server
}
/**
* Converts a JSON property name to a Java style property name.
*
*
* For example, 'storage_device' becomes 'storageDevice'.
*
*
* @param type_name JSON style property name.
* @return Java style property name.
*/
@PackageScope
static String propertyName(final String type_name) {
type_name.replaceAll(/_([a-z])/, { it[1].toUpperCase() }) // type_name -> typeName
}
/**
* Converts a JSON property name to a class name.
*
*
* For example, 'storage_device' becomes 'StorageDevice'.
*
*
* @param type_name JSON style property name.
* @return Simple class name, i.e. name without the package.
*/
@PackageScope
static String className(final String type_name) {
type_name.replaceAll(/(?:^|_)([a-z])/, { it[1].toUpperCase() }) // type_name -> TypeName
}
/**
* Converts a class name to a JSON property name.
*
*
* For example, 'StorageDevice' becomes 'storage_device'.
*
*
* @param className Simple name of a class, i.e. name without the package.
* @return JSON style property name.
*/
@PackageScope
static String type_name(final String className) {
className.replaceAll(/([A-Z])([A-Z]+)/, { it[1] + it[2].toLowerCase() }) // RESOURCE -> Resource
.replaceFirst(/^([A-Z])/, { it[0].toLowerCase() }) // Server -> server
.replaceAll(/([A-Z])/, { '_' + it[0].toLowerCase() }) // storageDevice -> storage_device
}
/**
* Returns a Class corresponding to the named resource.
*
*
* For example, loadResourceClass('Server')
would return the Class for
* fi.linuxbox.upcloud.resource.Server
class.
*
*
* @param resourceClassName Simple name of the resource class, i.e. name without the package.
* @return Class for the named resource.
*/
private Class loadResourceClass(final String resourceClassName) {
Class clazz = null
try {
clazz = gcl.loadClass("${resourcePackageName}.$resourceClassName")
log.debug("Loaded resource $resourceClassName")
} catch (final ClassNotFoundException ignored) {
log.info("Generating resource $resourceClassName")
clazz = gcl.parseClass("""
package ${resourcePackageName}
import ${Resource.class.name}
class $resourceClassName extends ${Resource.class.simpleName} {
$resourceClassName(final Map kwargs = [:]) {
super(kwargs)
}
}
""")
}
clazz
}
/**
* Returns true
if the value
is a list wrapper.
*
*
* A list wrapper is a Map that has only one key and the value corresponding to that key is a list. This is a
* convention in the UpCloud API.
*
*
* @param value A map which to check.
* @return true
if value
is a list wrapper, false
otherwise.
*/
private static boolean isListWrapper(final Map value) {
final Set keys = value.keySet()
keys.size() == 1 && value[keys[0]] instanceof List
}
}