From 7bcd3eca8da6bdb2d23905cf65eb49e1c68bb6d1 Mon Sep 17 00:00:00 2001 From: "Adam D. Ruppe" Date: Mon, 11 Feb 2019 14:38:53 -0500 Subject: [PATCH] prototype of web.d 2.0 in cgi.d --- cgi.d | 720 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 677 insertions(+), 43 deletions(-) diff --git a/cgi.d b/cgi.d index b604236..fa2e5ae 100644 --- a/cgi.d +++ b/cgi.d @@ -612,6 +612,9 @@ class Cgi { environmentVariables = cast(const) environment.toAA; + string[] allPostNamesInOrder; + string[] allPostValuesInOrder; + foreach(arg; args[1 .. $]) { if(arg.startsWith("--")) { nextArgIs = arg[2 .. $]; @@ -694,6 +697,8 @@ class Cgi { if(requestMethod == Cgi.RequestMethod.POST) { auto parts = breakUp(arg); _post[parts[0]] ~= parts[1]; + allPostNamesInOrder ~= parts[0]; + allPostValuesInOrder ~= parts[1]; } else { if(_queryString.length) _queryString ~= "&"; @@ -712,7 +717,7 @@ class Cgi { cookies = keepLastOf(cookiesArray); queryString = _queryString; - getArray = cast(immutable) decodeVariables(queryString); + getArray = cast(immutable) decodeVariables(queryString, "&", &allGetNamesInOrder, &allGetValuesInOrder); get = keepLastOf(getArray); postArray = cast(immutable) _post; @@ -741,6 +746,13 @@ class Cgi { this.postJson = null; } + private { + string[] allPostNamesInOrder; + string[] allPostValuesInOrder; + string[] allGetNamesInOrder; + string[] allGetValuesInOrder; + } + CgiConnectionHandle getOutputFileHandle() { return _outputFileHandle; } @@ -842,7 +854,7 @@ class Cgi { } - auto ugh = decodeVariables(queryString); + auto ugh = decodeVariables(queryString, "&", &allGetNamesInOrder, &allGetValuesInOrder); getArray = assumeUnique(ugh); get = keepLastOf(getArray); @@ -1208,9 +1220,16 @@ class Cgi { // I used to not do it, but I think I should, since it is there... pps._post[pps.piece.name] ~= pps.piece.filename; pps._files[pps.piece.name] ~= pps.piece; - } else + + allPostNamesInOrder ~= pps.piece.name; + allPostValuesInOrder ~= pps.piece.filename; + } else { pps._post[pps.piece.name] ~= cast(string) pps.piece.content; + allPostNamesInOrder ~= pps.piece.name; + allPostValuesInOrder ~= cast(string) pps.piece.content; + } + /* stderr.writeln("RECEIVED: ", pps.piece.name, "=", pps.piece.content.length < 1000 @@ -1455,7 +1474,7 @@ class Cgi { if(pps.isJson) pps.postJson = cast(string) pps.buffer; else - pps._post = decodeVariables(cast(string) pps.buffer); + pps._post = decodeVariables(cast(string) pps.buffer, "&", &allPostNamesInOrder, &allPostValuesInOrder); version(preserveData) originalPostData = pps.buffer; } else { @@ -1620,7 +1639,7 @@ class Cgi { pathInfo = requestUri[pathInfoStarts..question]; } - auto ugh = decodeVariables(queryString); + auto ugh = decodeVariables(queryString, "&", &allGetNamesInOrder, &allGetValuesInOrder); getArray = cast(string[][string]) assumeUnique(ugh); if(header.indexOf("HTTP/1.0") != -1) { @@ -2671,18 +2690,28 @@ struct Uri { */ /// breaks down a url encoded string -string[][string] decodeVariables(string data, string separator = "&") { +string[][string] decodeVariables(string data, string separator = "&", string[]* namesInOrder = null, string[]* valuesInOrder = null) { auto vars = data.split(separator); string[][string] _get; foreach(var; vars) { auto equal = var.indexOf("="); + string name; + string value; if(equal == -1) { - _get[decodeComponent(var)] ~= ""; + name = decodeComponent(var); + value = ""; } else { //_get[decodeComponent(var[0..equal])] ~= decodeComponent(var[equal + 1 .. $].replace("+", " ")); // stupid + -> space conversion. - _get[decodeComponent(var[0..equal].replace("+", " "))] ~= decodeComponent(var[equal + 1 .. $].replace("+", " ")); + name = decodeComponent(var[0..equal].replace("+", " ")); + value = decodeComponent(var[equal + 1 .. $].replace("+", " ")); } + + _get[name] ~= value; + if(namesInOrder) + (*namesInOrder) ~= name; + if(valuesInOrder) + (*valuesInOrder) ~= value; } return _get; } @@ -5659,38 +5688,50 @@ ssize_t read_fd(int fd, void *ptr, size_t nbytes, int *recvfd) { switch to choose if you want to override. */ -/+ -struct StaticFile { - string path; - string file; - // the following will be guessed automatically from the file type - string contentType; - bool gzip; - bool cache; -} - - -with(cgi.urlDispatcher()) { - -} -+/ struct DispatcherDefinition(alias dispatchHandler) {// if(is(typeof(dispatchHandler("str", Cgi.init) == bool))) { // bool delegate(string urlPrefix, Cgi cgi) dispatchHandler; alias handler = dispatchHandler; string urlPrefix; bool rejectFurther; } -struct CallableFromWeb { - Cgi.RequestMethod httpMethod; - string path; - void delegate(Cgi cgi) callFromCgi; - string[] parameters; -} - private string urlify(string name) { return name; } +private string beautify(string name) { + char[160] buffer; + int bufferIndex = 0; + bool shouldCap = true; + bool shouldSpace; + bool lastWasCap; + foreach(idx, char ch; name) { + if(bufferIndex == buffer.length) return name; // out of space, just give up, not that important + + if(ch >= 'A' && ch <= 'Z') { + if(lastWasCap) { + // two caps in a row, don't change. Prolly acronym. + } else { + if(idx) + shouldSpace = true; // new word, add space + } + + lastWasCap = true; + } + + if(shouldSpace) { + buffer[bufferIndex++] = ' '; + if(bufferIndex == buffer.length) return name; // out of space, just give up, not that important + } + if(shouldCap) { + if(ch >= 'a' && ch <= 'z') + ch -= 32; + shouldCap = false; + } + buffer[bufferIndex++] = ch; + } + return buffer[0 .. bufferIndex].idup; +} + /+ Argument conversions: for the most part, it is to!Thing(string). @@ -5728,47 +5769,522 @@ private string urlify(string name) { } foo&foo.a=1&foo.a=2&foo&foo.a=1 + + + Associative arrays are formatted with brackets, after a declaration, like structs: + + foo&foo[key]=value&foo[other_key]=value + + + Note: for maximum compatibility with outside code, keep your types simple. Some libraries + do not support the strict ordering requirements to work with these struct protocols. + + FIXME: also perhaps accept application/json to better work with outside trash. + + + Return values are also auto-formatted according to user-requested type: + for json, it loops over and converts. + for html, basic types are strings. Arrays are
    . Structs are
    . Arrays of structs are tables! +/ -void callFromCgi(alias method, T)(T dg, Cgi cgi) { + +// actually returns an arsd.dom.Form +auto createAutomaticFormForFunction(alias method, T)(T dg) { + import arsd.dom; + + auto form = cast(Form) Element.make("form"); + + form.addClass("automatic-form"); + + form.addChild("h3", beautify(__traits(identifier, method))); + import std.traits; - Parameters!method params; - static if(is(ReturnType!method == void)) { - dg(params); - } else { - auto ret = dg(params); - cgi.write(ret, true); + //Parameters!method params; + //alias idents = ParameterIdentifierTuple!method; + //alias defaults = ParameterDefaults!method; + + static Element elementFor(T)(string displayName, string name) { + auto div = Element.make("div"); + div.addClass("form-field"); + + static if(is(T == struct)) { + if(displayName !is null) + div.addChild("span", displayName, "label-text"); + auto fieldset = div.addChild("fieldset"); + fieldset.addChild("legend", beautify(T.stringof)); // FIXME + fieldset.addChild("input", name); + static foreach(idx, memberName; __traits(allMembers, T)) + static if(__traits(compiles, __traits(getMember, T, memberName).offsetof)) { + fieldset.appendChild(elementFor!(typeof(__traits(getMember, T, memberName)))(beautify(memberName), name ~ "." ~ memberName)); + } + } else static if(isSomeString!T || isIntegral!T || isFloatingPoint!T) { + Element lbl; + if(displayName !is null) { + lbl = div.addChild("label"); + lbl.addChild("span", displayName, "label-text"); + lbl.appendText(" "); + } else { + lbl = div; + } + auto i = lbl.addChild("input", name); + i.attrs.name = name; + static if(isSomeString!T) + i.attrs.type = "text"; + else + i.attrs.type = "number"; + i.attrs.value = to!string(T.init); + } else static if(is(T == K[], K)) { + auto templ = div.addChild("template"); + templ.appendChild(elementFor!(K)(null, name)); + if(displayName !is null) + div.addChild("span", displayName, "label-text"); + auto btn = div.addChild("button"); + btn.addClass("add-array-button"); + btn.attrs.type = "button"; + btn.innerText = "Add"; + btn.attrs.onclick = q{ + var a = document.importNode(this.parentNode.firstChild.content, true); + this.parentNode.insertBefore(a, this); + }; + } else static if(is(T == V[K], K, V)) { + div.innerText = "assoc array not implemented for automatic form at this time"; + } else { + static assert(0, "unsupported type for cgi call " ~ T.stringof); + } + + + return div; + } + + static if(is(typeof(method) P == __parameters)) + static foreach(idx, _; P) {{ + alias param = P[idx .. idx + 1]; + string displayName = beautify(__traits(identifier, param)); + static foreach(attr; __traits(getAttributes, param)) + static if(is(typeof(attr) == DisplayName)) + displayName = attr.name; + form.appendChild(elementFor!(param)(displayName, __traits(identifier, param))); + }} + + form.addChild("div", Html(``), "submit-button-holder"); + + return form; +} + +/* +string urlFor(alias func)() { + return __traits(identifier, func); +} +*/ + +/++ + UDA: The name displayed to the user in auto-generated HTML. + + Default is `beautify(identifier)`. ++/ +struct DisplayName { + string name; +} + +/++ + UDA: The name used in the URL or web parameter. + + Default is `urlify(identifier)` for functions and `identifier` for parameters and data members. ++/ +struct UrlName { + string name; +} + +class MissingArgumentException : Exception { + string functionName; + string argumentName; + string argumentType; + + this(string functionName, string argumentName, string argumentType, string file = __FILE__, size_t line = __LINE__, Throwable next = null) { + this.functionName = functionName; + this.argumentName = argumentName; + this.argumentType = argumentType; + + super("Missing Argument", file, line, next); } } -class WebObject { +auto callFromCgi(alias method, T)(T dg, Cgi cgi) { + + // FIXME: think more about checkboxes and bools. + + import std.traits; + + Parameters!method params; + alias idents = ParameterIdentifierTuple!method; + alias defaults = ParameterDefaults!method; + + const(string)[] names; + const(string)[] values; + + // first, check for missing arguments and initialize to defaults if necessary + static foreach(idx, param; params) {{ + auto ident = idents[idx]; + if(cgi.requestMethod == Cgi.RequestMethod.POST) { + if(ident !in cgi.post) { + static if(is(defaults[idx] == void)) + throw new MissingArgumentException(__traits(identifier, method), ident, typeof(param).stringof); + else + params[idx] = defaults[idx]; + } + } else { + if(ident !in cgi.get) { + static if(is(defaults[idx] == void)) + throw new MissingArgumentException(__traits(identifier, method), ident, typeof(param).stringof); + else + params[idx] = defaults[idx]; + } + } + }} + + // second, parse the arguments in order to build up arrays, etc. + + static bool setVariable(T)(string name, string paramName, T* what, string value) { + static if(is(T == struct)) { + if(name == paramName) { + *what = T.init; + return true; + } else { + // could be a child + if(name[paramName.length] == '.') { + paramName = name[paramName.length + 1 .. $]; + name = paramName; + int p = 0; + foreach(ch; paramName) { + if(ch == '.' || ch == '[') + break; + p++; + } + + // set the child member + switch(paramName) { + static foreach(idx, memberName; __traits(allMembers, T)) + static if(__traits(compiles, __traits(getMember, T, memberName).offsetof)) { + // data member! + case memberName: + return setVariable(name, paramName, &(__traits(getMember, *what, memberName)), value); + } + default: + // ok, not a member + } + } + } + } else static if(isSomeString!T || isIntegral!T || isFloatingPoint!T) { + *what = to!T(value); + return true; + } else static if(is(T == K[], K)) { + K tmp; + if(name == paramName) { + // direct - set and append + if(setVariable(name, paramName, &tmp, value)) { + (*what) ~= tmp; + return true; + } else { + return false; + } + } else { + // child, append to last element + // FIXME: what about range violations??? + auto ptr = &(*what)[(*what).length - 1]; + return setVariable(name, paramName, ptr, value); + + } + } else static if(is(T == V[K], K, V)) { + // assoc array, name[key] is valid + if(name == paramName) { + // no action necessary + return true; + } else if(name[paramName.length] == '[') { + int count = 1; + auto idx = paramName.length + 1; + while(idx < name.length && count > 0) { + if(name[idx] == '[') + count++; + else if(name[idx] == ']') { + count--; + if(count == 0) break; + } + idx++; + } + if(idx == name.length) + return false; // malformed + + auto insideBrackets = name[paramName.length + 1 .. idx]; + auto afterName = name[idx + 1 .. $]; + + auto k = to!K(insideBrackets); + V v; + + name = name[0 .. paramName.length]; + writeln(name, afterName, " ", paramName); + + auto ret = setVariable(name ~ afterName, paramName, &v, value); + if(ret) { + (*what)[k] = v; + return true; + } + } + } else { + static assert(0, "unsupported type for cgi call " ~ T.stringof); + } + + return false; + } + + void setArgument(string name, string value) { + int p; + foreach(ch; name) { + if(ch == '.' || ch == '[') + break; + p++; + } + + auto paramName = name[0 .. p]; + + sw: switch(paramName) { + static foreach(idx, param; params) { + case idents[idx]: + setVariable(name, paramName, ¶ms[idx], value); + break sw; + } + default: + // ignore; not relevant argument + } + } + + if(cgi.requestMethod == Cgi.RequestMethod.POST) { + names = cgi.allPostNamesInOrder; + values = cgi.allPostValuesInOrder; + } else { + names = cgi.allGetNamesInOrder; + values = cgi.allGetValuesInOrder; + } + + foreach(idx, name; names) { + setArgument(name, values[idx]); + } + + static if(is(ReturnType!method == void)) { + typeof(null) ret; + dg(params); + } else { + auto ret = dg(params); + } + + // FIXME: format return values + // options are: json, html, csv. + // also may need to wrap in envelope format: none, html, or json. + return ret; +} + +auto formatReturnValueAsHtml(T)(T t) { + import arsd.dom; + import std.traits; + + static if(is(T == typeof(null))) { + return Element.make("span"); + } else static if(isIntegral!T || isSomeString!T || isFloatingPoint!T) { + return Element.make("span", to!string(t), "automatic-data-display"); + } else static if(is(T == V[K], K, V)) { + auto dl = Element.make("dl"); + dl.addClass("automatic-data-display"); + foreach(k, v; t) { + dl.addChild("dt", to!string(k)); + dl.addChild("dd", formatReturnValueAsHtml(v)); + } + return dl; + } else static if(is(T == struct)) { + auto dl = Element.make("dl"); + dl.addClass("automatic-data-display"); + + static foreach(idx, memberName; __traits(allMembers, T)) + static if(__traits(compiles, __traits(getMember, T, memberName).offsetof)) { + dl.addChild("dt", memberName); + dl.addChild("dt", formatReturnValueAsHtml(__traits(getMember, t, memberName))); + } + + return dl; + } else static if(is(T == E[], E)) { + static if(is(E == struct)) { + // an array of structs is kinda special in that I like + // having those formatted as tables. + auto table = cast(Table) Element.make("table"); + table.addClass("automatic-data-display"); + string[] names; + static foreach(idx, memberName; __traits(allMembers, E)) + static if(__traits(compiles, __traits(getMember, E, memberName).offsetof)) { + names ~= beautify(memberName); + } + table.appendHeaderRow(names); + + foreach(l; t) { + auto tr = table.appendRow(); + static foreach(idx, memberName; __traits(allMembers, E)) + static if(__traits(compiles, __traits(getMember, E, memberName).offsetof)) { + tr.addChild("td", formatReturnValueAsHtml(__traits(getMember, l, memberName))); + } + } + + return table; + } else { + // otherwise, I will just make a list. + auto ol = Element.make("ol"); + ol.addClass("automatic-data-display"); + foreach(e; t) + ol.addChild("li", formatReturnValueAsHtml(e)); + return ol; + } + } else static assert(0, "bad return value for cgi call " ~ T.stringof); + + assert(0); +} + +/++ + The base class for the [dispatcher] object support. ++/ +class WebObject() { Cgi cgi; void initialize(Cgi cgi) { this.cgi = cgi; } + + string script() { + return ` + `; + } + + string style() { + return ` + :root { + --mild-border: #ccc; + --middle-border: #999; + } + table.automatic-data-display { + border-collapse: collapse; + border: solid 1px var(--mild-border); + } + + table.automatic-data-display td { + vertical-align: top; + border: solid 1px var(--mild-border); + padding: 2px 4px; + } + + table.automatic-data-display th { + border: solid 1px var(--mild-border); + border-bottom: solid 1px var(--middle-border); + padding: 2px 4px; + } + + ol.automatic-data-display { + margin: 0px; + list-style-position: inside; + padding: 0px; + } + + .automatic-form { + max-width: 600px; + } + + .form-field { + margin: 0.5em; + padding-left: 0.5em; + } + + .label-text { + display: block; + font-weight: bold; + margin-left: -0.5em; + } + + .add-array-button { + + } + `; + } + + import arsd.dom; + Element htmlContainer() { + auto document = new Document(` + + + D Application + + + +
    + + +`, true, true); + + return document.requireElementById("container"); + } } /++ - Serves a class' methods. To be used with [dispatcher]. + Serves a class' methods, as a kind of low-state RPC over the web. To be used with [dispatcher]. Usage of this function will add a dependency on [arsd.dom] and [arsd.jsvar]. FIXME: explain this better +/ auto serveApi(T)(string urlPrefix) { + import arsd.dom; + import arsd.jsvar; + static bool handler(string urlPrefix, Cgi cgi) { auto obj = new T(); obj.initialize(cgi); switch(cgi.pathInfo[urlPrefix.length .. $]) { - static foreach(methodName; __traits(derivedMembers, T)) - //static if(is({ + static foreach(methodName; __traits(derivedMembers, T)){{ + static if(is(typeof(__traits(getMember, T, methodName)) P == __parameters)) { case urlify(methodName): - callFromCgi!(__traits(getMember, obj, methodName))(&__traits(getMember, obj, methodName), cgi); + switch(cgi.request("format", "html")) { + case "html": + auto container = obj.htmlContainer(); + try { + auto ret = callFromCgi!(__traits(getMember, obj, methodName))(&__traits(getMember, obj, methodName), cgi); + container.appendChild(formatReturnValueAsHtml(ret)); + } catch(MissingArgumentException mae) { + container.appendChild(Element.make("p", "Argument `" ~ mae.argumentName ~ "` of type `" ~ mae.argumentType ~ "` is missing")); + container.appendChild(createAutomaticFormForFunction!(__traits(getMember, obj, methodName))(&__traits(getMember, obj, methodName))); + } + cgi.write(container.parentDocument.toString(), true); + break; + case "json": + auto ret = callFromCgi!(__traits(getMember, obj, methodName))(&__traits(getMember, obj, methodName), cgi); + var json = ret; + var envelope = var.emptyObject; + envelope.success = true; + envelope.result = json; + envelope.error = null; + cgi.setResponseContentType("application/json"); + cgi.write(envelope.toJson(), true); + + break; + default: + } return true; } + }} + case "script.js": + cgi.setResponseContentType("text/javascript"); + cgi.gzipResponse = true; + cgi.write(obj.script(), true); + return true; + case "style.css": + cgi.setResponseContentType("text/css"); + cgi.gzipResponse = true; + cgi.write(obj.style(), true); + return true; default: return false; } @@ -5779,9 +6295,127 @@ auto serveApi(T)(string urlPrefix) { } /++ - Serves a REST object. + Serves a REST object, similar to a Ruby on Rails resource. + + You put data members in your class. cgi.d will automatically make something out of those. + + It will call your constructor with the ID from the URL. This may be null. + It will then populate the data members from the request. + It will then call a method, if present, telling what happened. You don't need to write these! + It finally returns a reply. + + Your methods are passed a list of fields it actually set. + + The URL mapping - despite my general skepticism of the wisdom - matches up with what most REST + APIs I have used seem to follow. (I REALLY want to put trailing slashes on it though. Works better + with relative linking. But meh.) + + GET /items -> index. all values not set. + GET /items/id -> get. only ID will be set, other params ignored. + POST /items -> create. values set as given + PUT /items/id -> replace. values set as given + or POST /items/id with cgi.post["_method"] (thus urlencoded or multipart content-type) set to "PUT" to work around browser/html limitation + a GET with cgi.get["_method"] (in the url) set to "PUT" will render a form. + PATCH /items/id -> update. values set as given, list of changed fields passed + or POST /items/id with cgi.post["_method"] == "PATCH" + DELETE /items/id -> destroy. only ID guaranteed to be set + or POST /items/id with cgi.post["_method"] == "DELETE" + + Following the stupid convention, there will never be a trailing slash here, and if it is there, it will + redirect you away from it. + + API clients should set the `Accept` HTTP header to application/json or the cgi.get["_format"] = "json" var. + + I will also let you change the default, if you must. + + // One add-on is validation. You can issue a HTTP GET to a resource with _method = VALIDATE to check potential changes. + + You can define sub-resources on your object inside the object. These sub-resources are also REST objects + that follow the same thing. They may be individual resources or collections themselves. + + Your class is expected to have at least the following methods: + + FIXME: i kinda wanna add a routes object to the initialize call + + create + Create returns the new address on success, some code on failure. + show + index + update + remove + + You will want to be able to customize the HTTP, HTML, and JSON returns but generally shouldn't have to - the defaults + should usually work. The returned JSON will include a field "href" on all returned objects along with "id". Or omething like that. Usage of this function will add a dependency on [arsd.dom] and [arsd.jsvar]. + + NOT IMPLEMENTED + + + Really, a collection is a resource with a bunch of subresources. + + GET /items + index because it is GET on the top resource + + GET /items/foo + item but different than items? + + class Items { + + } + + ... but meh, a collection can be automated. not worth making it + a separate thing, let's look at a real example. Users has many + items and a virtual one, /users/current. + + the individual users have properties and two sub-resources: + session, which is just one, and comments, a collection. + + class User : RestObject!() { // no parent + int id; + string name; + + void show() {} // automated! GET of this specific thing + void create() {} // POST on a parent collection - this is called from a collection class after the members are updated + void replace() {} // this is the PUT; really, it just updates all fields. + void update() {} // PATCH, it updates some fields. + void remove() {} // DELETE + + void load(string urlId) {} // the default implementation of show() populates the id, then + + this() {} + + mixin Subresource!Session; + mixin Subresource!Comment; + } + + class Session : RestObject!() { + // the parent object may not be fully constructed/loaded + this(User parent) {} + + } + + class Comment : CollectionOf!Comment { + this(User parent) {} + } + + class Users : CollectionOf!User { + // but you don't strictly need ANYTHING on a collection; it will just... collect. Implement the subobjects. + void index() {} // GET on this specific thing; just like show really, just different name for the different semantics. + User create() {} // You MAY implement this, but the default is to create a new object, populate it from args, and then call create() on the child + } + + // so CollectionOf will mixin the stuff to forward it + + OK, the underlying functions are actually really low level + + GET(string url) + POST(string url) + PUT(string url) + + The url starts with the initial thing passed. + + It is the mixins that actually do the work. +/ auto serveRestObject(T)(string urlPrefix) { static bool handler(string urlPrefix, Cgi cgi) {