1 // Written in the D programming language
2 
3 /**
4    IronCache service wrapper by curl
5 
6    See_also
7    - $(LINK http://dev.iron.io/cache/reference/api/)
8 
9    License:  $(LINK2 http://boost.org/LICENSE_1_0.txt, Boost License 1.0)
10    Authors: karronoli
11    Copyright: karronoli 2015-
12    Date: 2015-Feb-15
13 
14    Examples:
15    ---
16    import iron.cache;
17    const prjid = "...", token = "...";
18    auto iron = new IronCache(prjid, token);
19    iron.put("myname", "mykey", "myvalue");
20    JSONValue json = iron.get("myname", "mykey");
21    assert(json["value"].str == "myvalue");
22    ---
23 */
24 
25 module iron.cache;
26 import core.time;
27 import std.json;
28 import curl = std.net.curl;
29 import std.string : format;
30 import std.uri : encodeComponent;
31 import std.exception : collectException;
32 debug {
33   import std.stdio;
34 }
35 
36 class IronCache
37 {
38   protected string base;
39   protected curl.HTTP delegate() client;
40   static const DEFAULT_HOST = "https://cache-aws-us-east-1.iron.io:443";
41   static const DEFAULT_API_VERSION = "1";
42   static const DEFAULT_TIMEOUT = 3.seconds;
43   /// See_Also: http://dev.iron.io/cache/reference/environment/
44   static const MAX_KEY_LENGTH = 250;
45   static const MAX_VALUE_SIZE = 1000000;
46 
47   @trusted
48   this(in string projectId, in string token, in string host = null) nothrow
49   {
50     this.base = (host? host: DEFAULT_HOST)
51       ~ '/' ~ DEFAULT_API_VERSION
52       ~ "/projects/" ~ projectId ~ "/caches";
53     this.client = () {
54       auto curl = curl.HTTP();
55       curl.operationTimeout(DEFAULT_TIMEOUT);
56       curl.addRequestHeader("Content-Type", "application/json; charset=utf-8");
57       curl.addRequestHeader("Authorization", "OAuth " ~ token);
58       return curl;
59     };
60   }
61 
62   /**
63    * constructor for iron.json configuration.
64    * Throws: FileException or JSONException without valid file.
65    */
66   @trusted
67   this(in string _path = null)
68   {
69     import std.file : isFile, readText;
70     const path = (_path != null && _path.isFile)? _path: "iron.json";
71     auto config = parseJSON(readText(path));
72     auto host = ("host" in config)? config["host"].str: null;
73     this(config["project_id"].str, config["token"].str, host);
74   }
75 
76   unittest
77   {
78     import std.file : FileException;
79     assert(new IronCache());
80     assert(collectException!FileException(new IronCache("none.json")));
81     auto iron = new IronCache("badprjid", "badtoken");
82     auto e = collectException!ICSException(iron.caches());
83     assert(e.status.code == 404, e.toString);
84     auto name = "badname";
85     assert(collectException!ICSException(iron.caches(name)));
86     assert(collectException!ICSException(iron.caches(name)));
87     assert(collectException!ICSException(iron.clear(name)));
88     assert(collectException!ICSException(iron.put(name, "k", "v")));
89     assert(collectException!ICSException(iron.get(name, "k")));
90     assert(collectException!ICSException(iron.increment(name, "k", 1)));
91     assert(collectException!ICSException(iron.remove(name, "k")));
92     assert(collectException!ICSException(iron.remove(name)));
93   }
94 
95   /**
96    * Throws:
97    *  IronCacheStatusException(bad network)
98    *  JSONException(bad response)
99    * Returns: http://dev.iron.io/cache/reference/api/#list_caches
100    */
101   @trusted
102   public JSONValue caches(in uint page = 0)
103   {
104     const url = this.base ~ format("?page=%d", page);
105     auto c = this.client();
106     string res;
107     if (auto e = collectException
108         (cast(string)curl.get!(curl.HTTP, ubyte)(url, c), res))
109       throw new ICSException(c.statusLine, __FILE__, __LINE__, e);
110     debug {
111       stderr.writefln("[iron]%s: %s", __FUNCTION__, res);
112     }
113     return parseJSON(res);
114   }
115 
116   /**
117    * Throws:
118    *  IronCacheStatusException(bad network)
119    *  JSONException(bad response)
120    * Returns: http://dev.iron.io/cache/reference/api/#get_info_about_a_cache
121    */
122   @trusted
123   public JSONValue caches(in string name)
124   {
125     const url = this.base ~ '/' ~ encodeComponent(name);
126     auto c = this.client();
127     string res;
128     if (auto e = collectException
129         (cast(string)curl.get!(curl.HTTP, ubyte)(url, c), res))
130       throw new ICSException(c.statusLine, __FILE__, __LINE__, e);
131     debug {
132       stderr.writefln("[iron]%s: %s", __FUNCTION__, res);
133     }
134     return parseJSON(res);
135   }
136 
137   /// Throws: IronCacheStatusException(bad network)
138   @trusted
139   public bool clear(in string name)
140   {
141     const url = this.base ~ format("/%s/clear", encodeComponent(name));
142     auto c = this.client();
143     char[] res;
144     if (auto e = collectException(curl.post(url, "", c), res))
145       throw new ICSException(c.statusLine, __FILE__, __LINE__, e);
146     debug {
147       stderr.writefln("[iron]%s: %s", __FUNCTION__, res);
148     }
149     return true;
150   }
151 
152   /// Throws: IronCacheStatusException(bad network)
153   @trusted
154   public bool put(in string name, in string key, in string value)
155     in {
156       assert(key.length <= MAX_KEY_LENGTH);
157       assert(value.length <= MAX_VALUE_SIZE);
158     }
159   body {
160     const url = this.base
161       ~ format("/%s/items/%s", encodeComponent(name), encodeComponent(key));
162     auto json = JSONValue(["value" : JSONValue(value)]);
163     return this.put(name, key, json);
164   }
165 
166   /// Throws: IronCacheStatusException(bad network)
167   @trusted
168   public bool put(in string name, in string key, in JSONValue json)
169     in {
170       assert(key.length <= MAX_KEY_LENGTH);
171       assert("value" in json.object);
172     }
173   body {
174     const url = this.base
175       ~ format("/%s/items/%s", encodeComponent(name), encodeComponent(key));
176     auto c = this.client();
177     char[] res;
178     if (auto e = collectException(curl.put(url, json.toString, c), res))
179       throw new ICSException(c.statusLine, __FILE__, __LINE__, e);
180     debug {
181       stderr.writefln("[iron]%s: %s", __FUNCTION__, res);
182     }
183     return true;
184   }
185 
186   /**
187    * Throws:
188    *  IronCacheStatusException(bad network)
189    *  JSONException(bad response)
190    * Returns: http://dev.iron.io/cache/reference/api/#get_an_item_from_a_cache
191    */
192   @trusted
193   public JSONValue get(in string name, in string key)
194   {
195     const url = this.base
196       ~ format("/%s/items/%s", encodeComponent(name), encodeComponent(key));
197     auto c = this.client();
198     string res;
199     if (auto e = collectException
200         (cast(string)curl.get!(curl.HTTP, ubyte)(url, c), res))
201       throw new ICSException(c.statusLine, __FILE__, __LINE__, e);
202     debug {
203       stderr.writefln("[iron]%s: %s", __FUNCTION__, res);
204     }
205     return parseJSON(res);
206   }
207 
208   /// Throws: IronCacheStatusException(bad network)
209   @trusted
210   public bool increment(in string name, in string key, in int amount)
211   {
212     const url = this.base
213       ~ format("/%s/items/%s/increment",
214                encodeComponent(name), encodeComponent(key));
215     auto json = JSONValue(["amount" : JSONValue(amount)]);
216     auto c = this.client();
217     char[] res;
218     if (auto e = collectException(curl.post(url, json.toString, c), res))
219       throw new ICSException(c.statusLine, __FILE__, __LINE__, e);
220     debug {
221       stderr.writefln("[iron]%s: %s", __FUNCTION__, res);
222     }
223     return true;
224   }
225 
226   /// Throws: IronCacheStatusException(bad network)
227   @trusted
228   public bool remove(in string name, in string key)
229   {
230     const url = this.base
231       ~ format("/%s/items/%s", encodeComponent(name), encodeComponent(key));
232     auto c = this.client();
233     if (auto e = collectException(curl.del(url, c)))
234       throw new ICSException(c.statusLine, __FILE__, __LINE__, e);
235     return true;
236   }
237 
238   /// Throws: IronCacheStatusException(bad network)
239   @trusted
240   public bool remove(in string name)
241   {
242     const url = this.base ~ '/' ~ encodeComponent(name);
243     auto c = this.client();
244     if (auto e = collectException(curl.del(url, c)))
245       throw new ICSException(c.statusLine, __FILE__, __LINE__, e);
246     return true;
247   }
248 }
249 
250 class IronCacheException : Exception
251 {
252   @safe pure nothrow
253   this(string msg,
254        string file = __FILE__,
255        size_t line = __LINE__,
256        Throwable next = null)
257   {
258     super(msg, file, line, next);
259   }
260 }
261 
262 class IronCacheStatusException : IronCacheException
263 {
264   protected curl.HTTP.StatusLine _status;
265   @safe pure nothrow
266   @property curl.HTTP.StatusLine status() const {return this._status;}
267 
268   @trusted
269   this(curl.HTTP.StatusLine status,
270        string file = __FILE__,
271        size_t line = __LINE__,
272        Throwable next = null)
273   {
274     this._status = status;
275     super(status.toString(), file, line, next);
276   }
277 }
278 alias ICSException = IronCacheStatusException;
279 
280 unittest
281 {
282   auto name = {
283     import std.random, std.ascii, std.conv;
284     auto n = "名前", rndGen = Random(unpredictableSeed);
285     return n ~ randomSample(letters.dtext, 10, rndGen).text;
286   }();
287 
288   auto iron = new IronCache();
289   auto e = collectException!IronCacheStatusException(iron.caches(name));
290   assert(e.status.code == 404, e.toString);
291 
292   const key1 = "鍵1", key2 = "鍵2", value = "値";
293   assert(iron.put(name, key1, value));
294   assert(iron.put(name, key2, JSONValue(["value": 1])));
295   assert(iron.increment(name, key2, 2));
296   assert(iron.increment(name, key2, -3));
297 
298   assert(iron.get(name, key1)["value"].str == value);
299   assert(iron.get(name, key2)["value"].integer == 0);
300   assert(iron.caches(name)["size"].integer == 2);
301   import std.algorithm : filter;
302   assert(! iron.caches().array.filter!(a => a["name"].str == name).empty);
303 
304   assert(iron.remove(name, key1));
305   assert(iron.caches(name)["size"].integer == 1);
306   assert(iron.clear(name));
307   assert(iron.caches(name)["size"].integer == 0);
308   assert(iron.remove(name));
309   assert(collectException!IronCacheStatusException(iron.caches(name)));
310 }