diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/404.html b/404.html new file mode 100644 index 0000000..44e9e08 --- /dev/null +++ b/404.html @@ -0,0 +1,403 @@ + + + + + + + + + + + + + + + + + + + + Selva + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ +

404 - Not found

+ +
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/assets/images/favicon.png b/assets/images/favicon.png new file mode 100644 index 0000000..1cf13b9 Binary files /dev/null and b/assets/images/favicon.png differ diff --git a/assets/javascripts/bundle.220ee61c.min.js b/assets/javascripts/bundle.220ee61c.min.js new file mode 100644 index 0000000..116072a --- /dev/null +++ b/assets/javascripts/bundle.220ee61c.min.js @@ -0,0 +1,29 @@ +"use strict";(()=>{var Ci=Object.create;var gr=Object.defineProperty;var Ri=Object.getOwnPropertyDescriptor;var ki=Object.getOwnPropertyNames,Ht=Object.getOwnPropertySymbols,Hi=Object.getPrototypeOf,yr=Object.prototype.hasOwnProperty,nn=Object.prototype.propertyIsEnumerable;var rn=(e,t,r)=>t in e?gr(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,P=(e,t)=>{for(var r in t||(t={}))yr.call(t,r)&&rn(e,r,t[r]);if(Ht)for(var r of Ht(t))nn.call(t,r)&&rn(e,r,t[r]);return e};var on=(e,t)=>{var r={};for(var n in e)yr.call(e,n)&&t.indexOf(n)<0&&(r[n]=e[n]);if(e!=null&&Ht)for(var n of Ht(e))t.indexOf(n)<0&&nn.call(e,n)&&(r[n]=e[n]);return r};var Pt=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Pi=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of ki(t))!yr.call(e,o)&&o!==r&&gr(e,o,{get:()=>t[o],enumerable:!(n=Ri(t,o))||n.enumerable});return e};var yt=(e,t,r)=>(r=e!=null?Ci(Hi(e)):{},Pi(t||!e||!e.__esModule?gr(r,"default",{value:e,enumerable:!0}):r,e));var sn=Pt((xr,an)=>{(function(e,t){typeof xr=="object"&&typeof an!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(xr,function(){"use strict";function e(r){var n=!0,o=!1,i=null,s={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function a(O){return!!(O&&O!==document&&O.nodeName!=="HTML"&&O.nodeName!=="BODY"&&"classList"in O&&"contains"in O.classList)}function f(O){var Qe=O.type,De=O.tagName;return!!(De==="INPUT"&&s[Qe]&&!O.readOnly||De==="TEXTAREA"&&!O.readOnly||O.isContentEditable)}function c(O){O.classList.contains("focus-visible")||(O.classList.add("focus-visible"),O.setAttribute("data-focus-visible-added",""))}function u(O){O.hasAttribute("data-focus-visible-added")&&(O.classList.remove("focus-visible"),O.removeAttribute("data-focus-visible-added"))}function p(O){O.metaKey||O.altKey||O.ctrlKey||(a(r.activeElement)&&c(r.activeElement),n=!0)}function m(O){n=!1}function d(O){a(O.target)&&(n||f(O.target))&&c(O.target)}function h(O){a(O.target)&&(O.target.classList.contains("focus-visible")||O.target.hasAttribute("data-focus-visible-added"))&&(o=!0,window.clearTimeout(i),i=window.setTimeout(function(){o=!1},100),u(O.target))}function v(O){document.visibilityState==="hidden"&&(o&&(n=!0),Y())}function Y(){document.addEventListener("mousemove",N),document.addEventListener("mousedown",N),document.addEventListener("mouseup",N),document.addEventListener("pointermove",N),document.addEventListener("pointerdown",N),document.addEventListener("pointerup",N),document.addEventListener("touchmove",N),document.addEventListener("touchstart",N),document.addEventListener("touchend",N)}function B(){document.removeEventListener("mousemove",N),document.removeEventListener("mousedown",N),document.removeEventListener("mouseup",N),document.removeEventListener("pointermove",N),document.removeEventListener("pointerdown",N),document.removeEventListener("pointerup",N),document.removeEventListener("touchmove",N),document.removeEventListener("touchstart",N),document.removeEventListener("touchend",N)}function N(O){O.target.nodeName&&O.target.nodeName.toLowerCase()==="html"||(n=!1,B())}document.addEventListener("keydown",p,!0),document.addEventListener("mousedown",m,!0),document.addEventListener("pointerdown",m,!0),document.addEventListener("touchstart",m,!0),document.addEventListener("visibilitychange",v,!0),Y(),r.addEventListener("focus",d,!0),r.addEventListener("blur",h,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)})});var cn=Pt(Er=>{(function(e){var t=function(){try{return!!Symbol.iterator}catch(c){return!1}},r=t(),n=function(c){var u={next:function(){var p=c.shift();return{done:p===void 0,value:p}}};return r&&(u[Symbol.iterator]=function(){return u}),u},o=function(c){return encodeURIComponent(c).replace(/%20/g,"+")},i=function(c){return decodeURIComponent(String(c).replace(/\+/g," "))},s=function(){var c=function(p){Object.defineProperty(this,"_entries",{writable:!0,value:{}});var m=typeof p;if(m!=="undefined")if(m==="string")p!==""&&this._fromString(p);else if(p instanceof c){var d=this;p.forEach(function(B,N){d.append(N,B)})}else if(p!==null&&m==="object")if(Object.prototype.toString.call(p)==="[object Array]")for(var h=0;hd[0]?1:0}),c._entries&&(c._entries={});for(var p=0;p1?i(d[1]):"")}})})(typeof global!="undefined"?global:typeof window!="undefined"?window:typeof self!="undefined"?self:Er);(function(e){var t=function(){try{var o=new e.URL("b","http://a");return o.pathname="c d",o.href==="http://a/c%20d"&&o.searchParams}catch(i){return!1}},r=function(){var o=e.URL,i=function(f,c){typeof f!="string"&&(f=String(f)),c&&typeof c!="string"&&(c=String(c));var u=document,p;if(c&&(e.location===void 0||c!==e.location.href)){c=c.toLowerCase(),u=document.implementation.createHTMLDocument(""),p=u.createElement("base"),p.href=c,u.head.appendChild(p);try{if(p.href.indexOf(c)!==0)throw new Error(p.href)}catch(O){throw new Error("URL unable to set base "+c+" due to "+O)}}var m=u.createElement("a");m.href=f,p&&(u.body.appendChild(m),m.href=m.href);var d=u.createElement("input");if(d.type="url",d.value=f,m.protocol===":"||!/:/.test(m.href)||!d.checkValidity()&&!c)throw new TypeError("Invalid URL");Object.defineProperty(this,"_anchorElement",{value:m});var h=new e.URLSearchParams(this.search),v=!0,Y=!0,B=this;["append","delete","set"].forEach(function(O){var Qe=h[O];h[O]=function(){Qe.apply(h,arguments),v&&(Y=!1,B.search=h.toString(),Y=!0)}}),Object.defineProperty(this,"searchParams",{value:h,enumerable:!0});var N=void 0;Object.defineProperty(this,"_updateSearchParams",{enumerable:!1,configurable:!1,writable:!1,value:function(){this.search!==N&&(N=this.search,Y&&(v=!1,this.searchParams._fromString(this.search),v=!0))}})},s=i.prototype,a=function(f){Object.defineProperty(s,f,{get:function(){return this._anchorElement[f]},set:function(c){this._anchorElement[f]=c},enumerable:!0})};["hash","host","hostname","port","protocol"].forEach(function(f){a(f)}),Object.defineProperty(s,"search",{get:function(){return this._anchorElement.search},set:function(f){this._anchorElement.search=f,this._updateSearchParams()},enumerable:!0}),Object.defineProperties(s,{toString:{get:function(){var f=this;return function(){return f.href}}},href:{get:function(){return this._anchorElement.href.replace(/\?$/,"")},set:function(f){this._anchorElement.href=f,this._updateSearchParams()},enumerable:!0},pathname:{get:function(){return this._anchorElement.pathname.replace(/(^\/?)/,"/")},set:function(f){this._anchorElement.pathname=f},enumerable:!0},origin:{get:function(){var f={"http:":80,"https:":443,"ftp:":21}[this._anchorElement.protocol],c=this._anchorElement.port!=f&&this._anchorElement.port!=="";return this._anchorElement.protocol+"//"+this._anchorElement.hostname+(c?":"+this._anchorElement.port:"")},enumerable:!0},password:{get:function(){return""},set:function(f){},enumerable:!0},username:{get:function(){return""},set:function(f){},enumerable:!0}}),i.createObjectURL=function(f){return o.createObjectURL.apply(o,arguments)},i.revokeObjectURL=function(f){return o.revokeObjectURL.apply(o,arguments)},e.URL=i};if(t()||r(),e.location!==void 0&&!("origin"in e.location)){var n=function(){return e.location.protocol+"//"+e.location.hostname+(e.location.port?":"+e.location.port:"")};try{Object.defineProperty(e.location,"origin",{get:n,enumerable:!0})}catch(o){setInterval(function(){e.location.origin=n()},100)}}})(typeof global!="undefined"?global:typeof window!="undefined"?window:typeof self!="undefined"?self:Er)});var qr=Pt((Mt,Nr)=>{/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */(function(t,r){typeof Mt=="object"&&typeof Nr=="object"?Nr.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Mt=="object"?Mt.ClipboardJS=r():t.ClipboardJS=r()})(Mt,function(){return function(){var e={686:function(n,o,i){"use strict";i.d(o,{default:function(){return Ai}});var s=i(279),a=i.n(s),f=i(370),c=i.n(f),u=i(817),p=i.n(u);function m(j){try{return document.execCommand(j)}catch(T){return!1}}var d=function(T){var E=p()(T);return m("cut"),E},h=d;function v(j){var T=document.documentElement.getAttribute("dir")==="rtl",E=document.createElement("textarea");E.style.fontSize="12pt",E.style.border="0",E.style.padding="0",E.style.margin="0",E.style.position="absolute",E.style[T?"right":"left"]="-9999px";var H=window.pageYOffset||document.documentElement.scrollTop;return E.style.top="".concat(H,"px"),E.setAttribute("readonly",""),E.value=j,E}var Y=function(T,E){var H=v(T);E.container.appendChild(H);var I=p()(H);return m("copy"),H.remove(),I},B=function(T){var E=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},H="";return typeof T=="string"?H=Y(T,E):T instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(T==null?void 0:T.type)?H=Y(T.value,E):(H=p()(T),m("copy")),H},N=B;function O(j){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?O=function(E){return typeof E}:O=function(E){return E&&typeof Symbol=="function"&&E.constructor===Symbol&&E!==Symbol.prototype?"symbol":typeof E},O(j)}var Qe=function(){var T=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},E=T.action,H=E===void 0?"copy":E,I=T.container,q=T.target,Me=T.text;if(H!=="copy"&&H!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(q!==void 0)if(q&&O(q)==="object"&&q.nodeType===1){if(H==="copy"&&q.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(H==="cut"&&(q.hasAttribute("readonly")||q.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(Me)return N(Me,{container:I});if(q)return H==="cut"?h(q):N(q,{container:I})},De=Qe;function $e(j){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?$e=function(E){return typeof E}:$e=function(E){return E&&typeof Symbol=="function"&&E.constructor===Symbol&&E!==Symbol.prototype?"symbol":typeof E},$e(j)}function Ei(j,T){if(!(j instanceof T))throw new TypeError("Cannot call a class as a function")}function tn(j,T){for(var E=0;E0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof I.action=="function"?I.action:this.defaultAction,this.target=typeof I.target=="function"?I.target:this.defaultTarget,this.text=typeof I.text=="function"?I.text:this.defaultText,this.container=$e(I.container)==="object"?I.container:document.body}},{key:"listenClick",value:function(I){var q=this;this.listener=c()(I,"click",function(Me){return q.onClick(Me)})}},{key:"onClick",value:function(I){var q=I.delegateTarget||I.currentTarget,Me=this.action(q)||"copy",kt=De({action:Me,container:this.container,target:this.target(q),text:this.text(q)});this.emit(kt?"success":"error",{action:Me,text:kt,trigger:q,clearSelection:function(){q&&q.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(I){return vr("action",I)}},{key:"defaultTarget",value:function(I){var q=vr("target",I);if(q)return document.querySelector(q)}},{key:"defaultText",value:function(I){return vr("text",I)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(I){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return N(I,q)}},{key:"cut",value:function(I){return h(I)}},{key:"isSupported",value:function(){var I=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],q=typeof I=="string"?[I]:I,Me=!!document.queryCommandSupported;return q.forEach(function(kt){Me=Me&&!!document.queryCommandSupported(kt)}),Me}}]),E}(a()),Ai=Li},828:function(n){var o=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function s(a,f){for(;a&&a.nodeType!==o;){if(typeof a.matches=="function"&&a.matches(f))return a;a=a.parentNode}}n.exports=s},438:function(n,o,i){var s=i(828);function a(u,p,m,d,h){var v=c.apply(this,arguments);return u.addEventListener(m,v,h),{destroy:function(){u.removeEventListener(m,v,h)}}}function f(u,p,m,d,h){return typeof u.addEventListener=="function"?a.apply(null,arguments):typeof m=="function"?a.bind(null,document).apply(null,arguments):(typeof u=="string"&&(u=document.querySelectorAll(u)),Array.prototype.map.call(u,function(v){return a(v,p,m,d,h)}))}function c(u,p,m,d){return function(h){h.delegateTarget=s(h.target,p),h.delegateTarget&&d.call(u,h)}}n.exports=f},879:function(n,o){o.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},o.nodeList=function(i){var s=Object.prototype.toString.call(i);return i!==void 0&&(s==="[object NodeList]"||s==="[object HTMLCollection]")&&"length"in i&&(i.length===0||o.node(i[0]))},o.string=function(i){return typeof i=="string"||i instanceof String},o.fn=function(i){var s=Object.prototype.toString.call(i);return s==="[object Function]"}},370:function(n,o,i){var s=i(879),a=i(438);function f(m,d,h){if(!m&&!d&&!h)throw new Error("Missing required arguments");if(!s.string(d))throw new TypeError("Second argument must be a String");if(!s.fn(h))throw new TypeError("Third argument must be a Function");if(s.node(m))return c(m,d,h);if(s.nodeList(m))return u(m,d,h);if(s.string(m))return p(m,d,h);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function c(m,d,h){return m.addEventListener(d,h),{destroy:function(){m.removeEventListener(d,h)}}}function u(m,d,h){return Array.prototype.forEach.call(m,function(v){v.addEventListener(d,h)}),{destroy:function(){Array.prototype.forEach.call(m,function(v){v.removeEventListener(d,h)})}}}function p(m,d,h){return a(document.body,m,d,h)}n.exports=f},817:function(n){function o(i){var s;if(i.nodeName==="SELECT")i.focus(),s=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var a=i.hasAttribute("readonly");a||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),a||i.removeAttribute("readonly"),s=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var f=window.getSelection(),c=document.createRange();c.selectNodeContents(i),f.removeAllRanges(),f.addRange(c),s=f.toString()}return s}n.exports=o},279:function(n){function o(){}o.prototype={on:function(i,s,a){var f=this.e||(this.e={});return(f[i]||(f[i]=[])).push({fn:s,ctx:a}),this},once:function(i,s,a){var f=this;function c(){f.off(i,c),s.apply(a,arguments)}return c._=s,this.on(i,c,a)},emit:function(i){var s=[].slice.call(arguments,1),a=((this.e||(this.e={}))[i]||[]).slice(),f=0,c=a.length;for(f;f{"use strict";/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */var rs=/["'&<>]/;Yo.exports=ns;function ns(e){var t=""+e,r=rs.exec(t);if(!r)return t;var n,o="",i=0,s=0;for(i=r.index;i0&&i[i.length-1])&&(c[0]===6||c[0]===2)){r=0;continue}if(c[0]===3&&(!i||c[1]>i[0]&&c[1]=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function W(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var n=r.call(e),o,i=[],s;try{for(;(t===void 0||t-- >0)&&!(o=n.next()).done;)i.push(o.value)}catch(a){s={error:a}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(s)throw s.error}}return i}function D(e,t,r){if(r||arguments.length===2)for(var n=0,o=t.length,i;n1||a(m,d)})})}function a(m,d){try{f(n[m](d))}catch(h){p(i[0][3],h)}}function f(m){m.value instanceof et?Promise.resolve(m.value.v).then(c,u):p(i[0][2],m)}function c(m){a("next",m)}function u(m){a("throw",m)}function p(m,d){m(d),i.shift(),i.length&&a(i[0][0],i[0][1])}}function pn(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof Ee=="function"?Ee(e):e[Symbol.iterator](),r={},n("next"),n("throw"),n("return"),r[Symbol.asyncIterator]=function(){return this},r);function n(i){r[i]=e[i]&&function(s){return new Promise(function(a,f){s=e[i](s),o(a,f,s.done,s.value)})}}function o(i,s,a,f){Promise.resolve(f).then(function(c){i({value:c,done:a})},s)}}function C(e){return typeof e=="function"}function at(e){var t=function(n){Error.call(n),n.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var It=at(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: +`+r.map(function(n,o){return o+1+") "+n.toString()}).join(` + `):"",this.name="UnsubscriptionError",this.errors=r}});function Ve(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var Ie=function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,n,o,i;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=Ee(s),f=a.next();!f.done;f=a.next()){var c=f.value;c.remove(this)}}catch(v){t={error:v}}finally{try{f&&!f.done&&(r=a.return)&&r.call(a)}finally{if(t)throw t.error}}else s.remove(this);var u=this.initialTeardown;if(C(u))try{u()}catch(v){i=v instanceof It?v.errors:[v]}var p=this._finalizers;if(p){this._finalizers=null;try{for(var m=Ee(p),d=m.next();!d.done;d=m.next()){var h=d.value;try{ln(h)}catch(v){i=i!=null?i:[],v instanceof It?i=D(D([],W(i)),W(v.errors)):i.push(v)}}}catch(v){n={error:v}}finally{try{d&&!d.done&&(o=m.return)&&o.call(m)}finally{if(n)throw n.error}}}if(i)throw new It(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)ln(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&Ve(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&Ve(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=function(){var t=new e;return t.closed=!0,t}(),e}();var Sr=Ie.EMPTY;function jt(e){return e instanceof Ie||e&&"closed"in e&&C(e.remove)&&C(e.add)&&C(e.unsubscribe)}function ln(e){C(e)?e():e.unsubscribe()}var Le={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var st={setTimeout:function(e,t){for(var r=[],n=2;n0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var n=this,o=this,i=o.hasError,s=o.isStopped,a=o.observers;return i||s?Sr:(this.currentObservers=null,a.push(r),new Ie(function(){n.currentObservers=null,Ve(a,r)}))},t.prototype._checkFinalizedStatuses=function(r){var n=this,o=n.hasError,i=n.thrownError,s=n.isStopped;o?r.error(i):s&&r.complete()},t.prototype.asObservable=function(){var r=new F;return r.source=this,r},t.create=function(r,n){return new xn(r,n)},t}(F);var xn=function(e){ie(t,e);function t(r,n){var o=e.call(this)||this;return o.destination=r,o.source=n,o}return t.prototype.next=function(r){var n,o;(o=(n=this.destination)===null||n===void 0?void 0:n.next)===null||o===void 0||o.call(n,r)},t.prototype.error=function(r){var n,o;(o=(n=this.destination)===null||n===void 0?void 0:n.error)===null||o===void 0||o.call(n,r)},t.prototype.complete=function(){var r,n;(n=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||n===void 0||n.call(r)},t.prototype._subscribe=function(r){var n,o;return(o=(n=this.source)===null||n===void 0?void 0:n.subscribe(r))!==null&&o!==void 0?o:Sr},t}(x);var Et={now:function(){return(Et.delegate||Date).now()},delegate:void 0};var wt=function(e){ie(t,e);function t(r,n,o){r===void 0&&(r=1/0),n===void 0&&(n=1/0),o===void 0&&(o=Et);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=n,i._timestampProvider=o,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=n===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,n),i}return t.prototype.next=function(r){var n=this,o=n.isStopped,i=n._buffer,s=n._infiniteTimeWindow,a=n._timestampProvider,f=n._windowTime;o||(i.push(r),!s&&i.push(a.now()+f)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var n=this._innerSubscribe(r),o=this,i=o._infiniteTimeWindow,s=o._buffer,a=s.slice(),f=0;f0?e.prototype.requestAsyncId.call(this,r,n,o):(r.actions.push(this),r._scheduled||(r._scheduled=ut.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,n,o){var i;if(o===void 0&&(o=0),o!=null?o>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,n,o);var s=r.actions;n!=null&&((i=s[s.length-1])===null||i===void 0?void 0:i.id)!==n&&(ut.cancelAnimationFrame(n),r._scheduled=void 0)},t}(Wt);var Sn=function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var n=this._scheduled;this._scheduled=void 0;var o=this.actions,i;r=r||o.shift();do if(i=r.execute(r.state,r.delay))break;while((r=o[0])&&r.id===n&&o.shift());if(this._active=!1,i){for(;(r=o[0])&&r.id===n&&o.shift();)r.unsubscribe();throw i}},t}(Dt);var Oe=new Sn(wn);var M=new F(function(e){return e.complete()});function Vt(e){return e&&C(e.schedule)}function Cr(e){return e[e.length-1]}function Ye(e){return C(Cr(e))?e.pop():void 0}function Te(e){return Vt(Cr(e))?e.pop():void 0}function zt(e,t){return typeof Cr(e)=="number"?e.pop():t}var pt=function(e){return e&&typeof e.length=="number"&&typeof e!="function"};function Nt(e){return C(e==null?void 0:e.then)}function qt(e){return C(e[ft])}function Kt(e){return Symbol.asyncIterator&&C(e==null?void 0:e[Symbol.asyncIterator])}function Qt(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function zi(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var Yt=zi();function Gt(e){return C(e==null?void 0:e[Yt])}function Bt(e){return un(this,arguments,function(){var r,n,o,i;return $t(this,function(s){switch(s.label){case 0:r=e.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,et(r.read())];case 3:return n=s.sent(),o=n.value,i=n.done,i?[4,et(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,et(o)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function Jt(e){return C(e==null?void 0:e.getReader)}function U(e){if(e instanceof F)return e;if(e!=null){if(qt(e))return Ni(e);if(pt(e))return qi(e);if(Nt(e))return Ki(e);if(Kt(e))return On(e);if(Gt(e))return Qi(e);if(Jt(e))return Yi(e)}throw Qt(e)}function Ni(e){return new F(function(t){var r=e[ft]();if(C(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function qi(e){return new F(function(t){for(var r=0;r=2;return function(n){return n.pipe(e?A(function(o,i){return e(o,i,n)}):de,ge(1),r?He(t):Dn(function(){return new Zt}))}}function Vn(){for(var e=[],t=0;t=2,!0))}function pe(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new x}:t,n=e.resetOnError,o=n===void 0?!0:n,i=e.resetOnComplete,s=i===void 0?!0:i,a=e.resetOnRefCountZero,f=a===void 0?!0:a;return function(c){var u,p,m,d=0,h=!1,v=!1,Y=function(){p==null||p.unsubscribe(),p=void 0},B=function(){Y(),u=m=void 0,h=v=!1},N=function(){var O=u;B(),O==null||O.unsubscribe()};return y(function(O,Qe){d++,!v&&!h&&Y();var De=m=m!=null?m:r();Qe.add(function(){d--,d===0&&!v&&!h&&(p=$r(N,f))}),De.subscribe(Qe),!u&&d>0&&(u=new rt({next:function($e){return De.next($e)},error:function($e){v=!0,Y(),p=$r(B,o,$e),De.error($e)},complete:function(){h=!0,Y(),p=$r(B,s),De.complete()}}),U(O).subscribe(u))})(c)}}function $r(e,t){for(var r=[],n=2;ne.next(document)),e}function K(e,t=document){return Array.from(t.querySelectorAll(e))}function z(e,t=document){let r=ce(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function ce(e,t=document){return t.querySelector(e)||void 0}function _e(){return document.activeElement instanceof HTMLElement&&document.activeElement||void 0}function tr(e){return L(b(document.body,"focusin"),b(document.body,"focusout")).pipe(ke(1),l(()=>{let t=_e();return typeof t!="undefined"?e.contains(t):!1}),V(e===_e()),J())}function Xe(e){return{x:e.offsetLeft,y:e.offsetTop}}function Kn(e){return L(b(window,"load"),b(window,"resize")).pipe(Ce(0,Oe),l(()=>Xe(e)),V(Xe(e)))}function rr(e){return{x:e.scrollLeft,y:e.scrollTop}}function dt(e){return L(b(e,"scroll"),b(window,"resize")).pipe(Ce(0,Oe),l(()=>rr(e)),V(rr(e)))}var Yn=function(){if(typeof Map!="undefined")return Map;function e(t,r){var n=-1;return t.some(function(o,i){return o[0]===r?(n=i,!0):!1}),n}return function(){function t(){this.__entries__=[]}return Object.defineProperty(t.prototype,"size",{get:function(){return this.__entries__.length},enumerable:!0,configurable:!0}),t.prototype.get=function(r){var n=e(this.__entries__,r),o=this.__entries__[n];return o&&o[1]},t.prototype.set=function(r,n){var o=e(this.__entries__,r);~o?this.__entries__[o][1]=n:this.__entries__.push([r,n])},t.prototype.delete=function(r){var n=this.__entries__,o=e(n,r);~o&&n.splice(o,1)},t.prototype.has=function(r){return!!~e(this.__entries__,r)},t.prototype.clear=function(){this.__entries__.splice(0)},t.prototype.forEach=function(r,n){n===void 0&&(n=null);for(var o=0,i=this.__entries__;o0},e.prototype.connect_=function(){!Wr||this.connected_||(document.addEventListener("transitionend",this.onTransitionEnd_),window.addEventListener("resize",this.refresh),va?(this.mutationsObserver_=new MutationObserver(this.refresh),this.mutationsObserver_.observe(document,{attributes:!0,childList:!0,characterData:!0,subtree:!0})):(document.addEventListener("DOMSubtreeModified",this.refresh),this.mutationEventsAdded_=!0),this.connected_=!0)},e.prototype.disconnect_=function(){!Wr||!this.connected_||(document.removeEventListener("transitionend",this.onTransitionEnd_),window.removeEventListener("resize",this.refresh),this.mutationsObserver_&&this.mutationsObserver_.disconnect(),this.mutationEventsAdded_&&document.removeEventListener("DOMSubtreeModified",this.refresh),this.mutationsObserver_=null,this.mutationEventsAdded_=!1,this.connected_=!1)},e.prototype.onTransitionEnd_=function(t){var r=t.propertyName,n=r===void 0?"":r,o=ba.some(function(i){return!!~n.indexOf(i)});o&&this.refresh()},e.getInstance=function(){return this.instance_||(this.instance_=new e),this.instance_},e.instance_=null,e}(),Gn=function(e,t){for(var r=0,n=Object.keys(t);r0},e}(),Jn=typeof WeakMap!="undefined"?new WeakMap:new Yn,Xn=function(){function e(t){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function.");if(!arguments.length)throw new TypeError("1 argument required, but only 0 present.");var r=ga.getInstance(),n=new La(t,r,this);Jn.set(this,n)}return e}();["observe","unobserve","disconnect"].forEach(function(e){Xn.prototype[e]=function(){var t;return(t=Jn.get(this))[e].apply(t,arguments)}});var Aa=function(){return typeof nr.ResizeObserver!="undefined"?nr.ResizeObserver:Xn}(),Zn=Aa;var eo=new x,Ca=$(()=>k(new Zn(e=>{for(let t of e)eo.next(t)}))).pipe(g(e=>L(ze,k(e)).pipe(R(()=>e.disconnect()))),X(1));function he(e){return{width:e.offsetWidth,height:e.offsetHeight}}function ye(e){return Ca.pipe(S(t=>t.observe(e)),g(t=>eo.pipe(A(({target:r})=>r===e),R(()=>t.unobserve(e)),l(()=>he(e)))),V(he(e)))}function bt(e){return{width:e.scrollWidth,height:e.scrollHeight}}function ar(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}var to=new x,Ra=$(()=>k(new IntersectionObserver(e=>{for(let t of e)to.next(t)},{threshold:0}))).pipe(g(e=>L(ze,k(e)).pipe(R(()=>e.disconnect()))),X(1));function sr(e){return Ra.pipe(S(t=>t.observe(e)),g(t=>to.pipe(A(({target:r})=>r===e),R(()=>t.unobserve(e)),l(({isIntersecting:r})=>r))))}function ro(e,t=16){return dt(e).pipe(l(({y:r})=>{let n=he(e),o=bt(e);return r>=o.height-n.height-t}),J())}var cr={drawer:z("[data-md-toggle=drawer]"),search:z("[data-md-toggle=search]")};function no(e){return cr[e].checked}function Ke(e,t){cr[e].checked!==t&&cr[e].click()}function Ue(e){let t=cr[e];return b(t,"change").pipe(l(()=>t.checked),V(t.checked))}function ka(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function Ha(){return L(b(window,"compositionstart").pipe(l(()=>!0)),b(window,"compositionend").pipe(l(()=>!1))).pipe(V(!1))}function oo(){let e=b(window,"keydown").pipe(A(t=>!(t.metaKey||t.ctrlKey)),l(t=>({mode:no("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),A(({mode:t,type:r})=>{if(t==="global"){let n=_e();if(typeof n!="undefined")return!ka(n,r)}return!0}),pe());return Ha().pipe(g(t=>t?M:e))}function le(){return new URL(location.href)}function ot(e){location.href=e.href}function io(){return new x}function ao(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)ao(e,r)}function _(e,t,...r){let n=document.createElement(e);if(t)for(let o of Object.keys(t))typeof t[o]!="undefined"&&(typeof t[o]!="boolean"?n.setAttribute(o,t[o]):n.setAttribute(o,""));for(let o of r)ao(n,o);return n}function fr(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function so(){return location.hash.substring(1)}function Dr(e){let t=_("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Pa(e){return L(b(window,"hashchange"),e).pipe(l(so),V(so()),A(t=>t.length>0),X(1))}function co(e){return Pa(e).pipe(l(t=>ce(`[id="${t}"]`)),A(t=>typeof t!="undefined"))}function Vr(e){let t=matchMedia(e);return er(r=>t.addListener(()=>r(t.matches))).pipe(V(t.matches))}function fo(){let e=matchMedia("print");return L(b(window,"beforeprint").pipe(l(()=>!0)),b(window,"afterprint").pipe(l(()=>!1))).pipe(V(e.matches))}function zr(e,t){return e.pipe(g(r=>r?t():M))}function ur(e,t={credentials:"same-origin"}){return ue(fetch(`${e}`,t)).pipe(fe(()=>M),g(r=>r.status!==200?Ot(()=>new Error(r.statusText)):k(r)))}function We(e,t){return ur(e,t).pipe(g(r=>r.json()),X(1))}function uo(e,t){let r=new DOMParser;return ur(e,t).pipe(g(n=>n.text()),l(n=>r.parseFromString(n,"text/xml")),X(1))}function pr(e){let t=_("script",{src:e});return $(()=>(document.head.appendChild(t),L(b(t,"load"),b(t,"error").pipe(g(()=>Ot(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(l(()=>{}),R(()=>document.head.removeChild(t)),ge(1))))}function po(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function lo(){return L(b(window,"scroll",{passive:!0}),b(window,"resize",{passive:!0})).pipe(l(po),V(po()))}function mo(){return{width:innerWidth,height:innerHeight}}function ho(){return b(window,"resize",{passive:!0}).pipe(l(mo),V(mo()))}function bo(){return G([lo(),ho()]).pipe(l(([e,t])=>({offset:e,size:t})),X(1))}function lr(e,{viewport$:t,header$:r}){let n=t.pipe(ee("size")),o=G([n,r]).pipe(l(()=>Xe(e)));return G([r,t,o]).pipe(l(([{height:i},{offset:s,size:a},{x:f,y:c}])=>({offset:{x:s.x-f,y:s.y-c+i},size:a})))}(()=>{function e(n,o){parent.postMessage(n,o||"*")}function t(...n){return n.reduce((o,i)=>o.then(()=>new Promise(s=>{let a=document.createElement("script");a.src=i,a.onload=s,document.body.appendChild(a)})),Promise.resolve())}var r=class extends EventTarget{constructor(n){super(),this.url=n,this.m=i=>{i.source===this.w&&(this.dispatchEvent(new MessageEvent("message",{data:i.data})),this.onmessage&&this.onmessage(i))},this.e=(i,s,a,f,c)=>{if(s===`${this.url}`){let u=new ErrorEvent("error",{message:i,filename:s,lineno:a,colno:f,error:c});this.dispatchEvent(u),this.onerror&&this.onerror(u)}};let o=document.createElement("iframe");o.hidden=!0,document.body.appendChild(this.iframe=o),this.w.document.open(),this.w.document.write(` + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + +

Configuration

+

Settings in Selva are handled through YAML files. +Internally it uses strictyaml to parse the +yaml files in order to do the parsing in a safe and predictable way.

+

Settings files are located by default in the configuration directory with the +base name settings.yaml:

+
project/
+├── application/
+│   └── ...
+└── configuration/
+    ├── settings.yaml
+    ├── settings_dev.yaml
+    └── settings_prod.yaml
+
+

Accessing the configuration

+

The configuration values can be accessed by injecting selva.configuration.Settings.

+
from typing import Annotated
+from selva.configuration import Settings
+from selva.di import Inject, service
+
+
+@service
+class MyService:
+    settings: Annotated[Settings, Inject]
+
+

The selva.configuration.Settings is a dict like object that can also be accessed +using property syntax:

+
from selva.configuration import Settings
+
+settings = Settings({"config": "value"})
+assert settings["config"] == "value"
+assert settings.config == "value"
+
+

Typed settings

+

Since strictyaml is used to parse the yaml files, all values strs. However, we +can use pydantic and Selva dependency injection system to provide access to the +settings in a typed manner:

+
+
+
+
from pydantic import BaseModel
+from selva.configuration import Settings
+from selva.di import service
+
+
+class MySettings(BaseModel):
+    int_property: int
+    bool_property: bool
+
+
+@service
+def my_settings(settings: Settings) -> MySettings:
+    return MySettings.model_validate(settings.my_settings)
+
+
+
+
my_settings:
+  int_property: 1
+  bool_property: true
+
+
+
+
+

Environment substitution

+

The settings files can include references to environment variables that takes the +format ${ENV_VAR:default_value}. The default value is optional and an error will +be raised if neither the environment variable nor the default value are defined.

+
required: ${ENV_VAR}         # required environment variable
+optional: ${OPT_VAR:default} # optional environment variable
+
+

Profiles

+

Optional profiles can be activated by settings the environment variable SELVA_PROFILE. +The framework will look for a file named settings_${SELVA_PROFILE}.yaml and merge +the values with the main settings.yaml. Values from the profile settings take +precedence over the values from the main settings.

+

As an example, if we define SELVA_PROFILE=dev, the file settings_dev.yaml will +be loaded. If instead we define SELVA_PROFILE=prod, then the file settings_prod.yaml +will be loaded.

+

Environment variables

+

Settings can also be defined with environment variables whose names start with SELVA__, +where subsequent double undercores (__) indicates nesting (variable is a mapping). +Also, variable names will be lowercased.

+

For example, consider the following environment variables:

+
SELVA__PROPERTY=1
+SELVA__MAPPING__PROPERTY=2
+SELVA__MAPPING__ANOTHER_PROPERTY=3
+
+

Those variables will be collected as the following:

+
{
+    "property": "1",
+    "mapping": {
+        "property": "2",
+        "another_property": "3",
+    },
+}
+
+

DotEnv

+

If running you project using selva.run.app, for example uvicorn selva.run:app, +environment variables can be loaded from a .env file. The parsing is done using +the python-dotenv library.

+

By default, a .env file in the current working directory will be loaded, but it +can be customized with the environment variable SELVA_DOTENV pointing to a .env file.

+ + + + + + +
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/controllers/index.html b/controllers/index.html new file mode 100644 index 0000000..a0f02f8 --- /dev/null +++ b/controllers/index.html @@ -0,0 +1,783 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Controllers - Selva + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + +

Controllers

+

Overview

+

Controllers are classes responsible for handling requests through handler methods. +They are defined using the @controller on the class and @get, @post, @put, +@patch, @delete and @websocket on each of the handler methods.

+

Handler methods must receive, at least, two parameters: Request and Response. +It is not needed to annotate the request and response parameters, but they should +be the first two parameters.

+
from asgikit.requests import Request, read_json
+from asgikit.responses import respond_text, respond_redirect
+from selva.web import controller, get, post
+from loguru import logger
+
+
+@controller
+class IndexController:
+    @get
+    async def index(self, request: Request):
+        await respond_text(request.response, "application root")
+
+
+@controller("admin")
+class AdminController:
+    @post("send")
+    async def handle_data(self, request: Request):
+        logger.info(await read_json(request))
+        await respond_redirect(request.response, "/")
+
+
+

Note

+

Defining a path on @controller or @get @post etc... is optional and +defaults to an empty string "".

+
+

Handler methods can be defined with path parameters, which can be bound to the +handler with the annotation FromPath:

+
from typing import Annotated
+from selva.web.converter import FromPath
+from selva.web.routing.decorator import get
+
+
+@get("/:path_param")
+def handler(request, path_param: Annotated[str, FromPath]):
+    ...
+
+

It is also possible to explicitly declare from which parameter the value will +be retrieved from:

+
@get("/:path_param")
+def handler(req, res, value: Annotated[str, FromPath("path_param")]):
+    ...
+
+

The routing section provides more information about path parameters

+

Dependencies

+

Controllers themselves are services, and therefore can have services injected.

+
from typing import Annotated
+from selva.di import service, Inject
+from selva.web import controller
+
+
+@service
+class MyService:
+    pass
+
+
+@controller
+class MyController:
+    my_service: Annotated[MyService, Inject]
+
+

Request Information

+

Handler methods receive an object of type asgikit.requests.Request as the first +parameter that provides access to request information (path, method, headers, query +string, request body). It also provides the asgikit.responses.Response or +asgikit.websockets.WebSocket objects to either respond the request or interact +with the client via websocket.

+
+

Attention

+

For http requests, Request.websocket will be None, and for +websocket requests, Request.response will be None

+
+
from http import HTTPMethod, HTTPStatus
+from asgikit.requests import Request
+from asgikit.responses import respond_json
+from selva.web import controller, get, websocket
+
+
+@controller
+class MyController:
+    @get
+    async def handler(self, request: Request):
+        assert request.response is not None
+        assert request.websocket is None
+
+        assert request.method == HTTPMethod.GET
+        assert request.path == "/"
+        await respond_json(request.response, {"status": HTTPStatus.OK})
+
+    @websocket
+    async def ws_handler(self, request: Request):
+        assert request.response is None
+        assert request.websocket is not None
+
+        ws = request.websocket
+        await ws.accept()
+        while True:
+            data = await ws.receive()
+            await ws.send(data)
+
+

Request body

+

asgikit provides several functions to retrieve the request body:

+
async def read_body(request: Request) -> bytes
+async def read_text(request: Request, encoding: str = None) -> str
+async def read_json(request: Request) -> dict | list
+async def read_form(request: Request) -> dict[str, str | multipart.File]:
+
+

Websockets

+

For websocket, there are the following methods:

+
async def accept(subprotocol: str = None, headers: Iterable[tuple[bytes, bytes]] = None)
+async def receive(self) -> str | bytes
+async def send(self, data: bytes | str)
+async def close(self, code: int = 1000, reason: str = "")
+
+

Request Parameters

+

Handler methods can receive additional parameters, which will be extracted from +the request using an implementation of selva.web.FromRequest[Type]. +If there is no direct implementation of FromRequest[Type], Selva will iterate +over the base types of Type until an implementation is found or an error will +be returned if there is none.

+

You can use the register_from_request decorator to register an FromRequest implementation.

+
from asgikit.requests import Request
+from asgikit.responses import respond_text
+from selva.web import controller, get
+from selva.web.converter.decorator import register_from_request
+
+
+class Param:
+    def __init__(self, path: str):
+        self.request_path = path
+
+
+@register_from_request(Param)
+class ParamFromRequest:
+    def from_request(
+        self,
+        request: Request,
+        original_type,
+        parameter_name,
+        metadata = None,
+    ) -> Param:
+        return Param(request.path)
+
+
+@controller
+class MyController:
+    @get
+    async def handler(self, request: Request, param: Param):
+        await respond_text(request.response, param.request_path)
+
+

If the FromRequest implementation raise an error, the handler is not called. +And if the error is a subclass of selva.web.error.HTTPError, for instance +UnathorizedError, a response will be produced according to the error.

+
from selva.web.exception import HTTPUnauthorizedException
+
+
+@register_from_request(Param)
+class ParamFromRequest:
+    def from_request(
+        self,
+        request: Request,
+        original_type,
+        parameter_name,
+        metadata = None,
+    ) -> Param:
+        if "authorization" not in request.headers:
+            raise HTTPUnauthorizedException()
+        return Param(context.path)
+
+

Pydantic

+

Selva already implements FromRequest[pydantic.BaseModel] by reading the request +body and parsing the input into the pydantic model, if the content type is json +or form, otherwise raising an HTTPError with status code 415. It is also implemented +for list[pydantic.BaseModel].

+

Responses

+

Inheriting the asgikit.responses.Response from asgikit, the handler methods +do not return a response, instead they write to the response.

+
from asgikit.requests import Request
+from asgikit.responses import respond_text
+from selva.web import controller, get
+
+
+@controller
+class Controller:
+    @get
+    async def handler(self, request: Request):
+        await respond_text(request.response, "Ok")
+
+ + + + + + +
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..3f88b45 --- /dev/null +++ b/index.html @@ -0,0 +1,505 @@ + + + + + + + + + + + + + + + + + + + + + + + + Selva + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + +

Selva

+

Selva is a tool for creating ASGI applications that are easy to build and maintain.

+

It is built on top of asgikit and comes with +a dependency injection system built upon Python type annotations. It is compatible with python 3.11+.

+

Quickstart

+

Install selva and uvicorn:

+
pip install selva uvicorn[standard]
+
+

Create file application.py:

+
from asgikit.requests import Request
+from asgikit.responses import respond_text
+from selva.web import controller, get
+
+
+@controller
+class Controller:
+    @get
+    async def hello(self, request: Request):
+        await respond_text(request.response, "Hello, World")
+
+

Run application with uvicorn. Selva will automatically load application.py:

+
uvicorn selva.run:app
+
+
INFO:     Started server process [18664]
+INFO:     Waiting for application startup.
+INFO:     Application startup complete.
+INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
+
+ + + + + + +
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/logging/index.html b/logging/index.html new file mode 100644 index 0000000..392fe8b --- /dev/null +++ b/logging/index.html @@ -0,0 +1,539 @@ + + + + + + + + + + + + + + + + + + + + + + + + Logging - Selva + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + +

Logging

+

Selva uses loguru for logging, but provides +some facilities on top of it to make its usage a bit closer to other frameworks +like Spring Boot.

+

First, an interceptor to the standard logging module is configured by default, +as suggested in https://github.com/Delgan/loguru#entirely-compatible-with-standard-logging.

+

Second, a custom logging filter is provided in order to set the logging level for +each package independently.

+

Configuring logging

+

Logging is configured in the Selva configuration:

+
logging:
+  root: WARNING
+  level:
+    application: INFO
+    application.service: TRACE
+    sqlalchemy: DEBUG
+  enable:
+    - packages_to_activate_logging
+  disabled:
+    - packages_to_deactivate_logging
+
+

The root property is the root level. It is used if no other level is set for the +package where the log comes from.

+

The level property defines the logging level for each package independently.

+

The enable and disable properties lists the packages to enable or disable logging. +This comes from loguru, as can be seen in https://github.com/Delgan/loguru#suitable-for-scripts-and-libraries.

+

Manual logger setup

+

If you want full control of how loguru is configured, you can provide a logger setup +function and reference it in the configuration file:

+
+
+
+
from loguru import logger
+
+
+def setup(settings):
+    logger.configure(...)
+
+
+
+
logging:
+  setup: application.logging.setup
+
+
+
+
+

The setup function receives a parameter of type selva.configuration.Settings, +so you can have access to the whole settings.

+ + + + + + +
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/middleware/index.html b/middleware/index.html new file mode 100644 index 0000000..de101d2 --- /dev/null +++ b/middleware/index.html @@ -0,0 +1,584 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Middleware - Selva + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + +

Middleware

+

The middleware pipeline is configured with the MIDDLEWARE configuration property. It must contain a list of classes +that inherit from selva.web.middleware.Middleware.

+

Usage

+

To demonstrate the middleware system, we will create a timing middleware that will output to the console the time spent +in the processing of the request:

+
+
+
+
from asgikit.requests import Request
+from asgikit.responses import respond_json
+from selva.web import controller, get
+
+
+@controller
+class HelloController:
+    @get
+    async def hello(self, request: Request):
+        await respond_json(request.response, {"greeting": "Hello, World!"})
+
+
+
+
from collections.abc import Callable
+from datetime import datetime
+
+from asgikit.requests import Request
+from selva.web.middleware import Middleware
+from loguru import logger
+
+
+class TimingMiddleware(Middleware):
+    async def __call__(self, chain: Callable, request: Request):
+        request_start = datetime.now()
+        await chain(request) # (1)
+        request_end = datetime.now()
+
+        delta = request_end - request_start
+        logger.info("Request time: {}", delta)
+
+
    +
  1. Invoke the middleware chain to process the request
  2. +
+
+
+
middleware:
+  - application.middleware.TimingMiddleware
+
+
+
+
+

Middleware dependencies

+

Middleware instances are created using the same machinery as services, and therefore +can have services of their own. Our TimingMiddleware, for instance, could persist +the timings using a service instead of printing to the console:

+
+
+
+
from datetime import datetime
+
+from selva.di import service
+
+@service
+class TimingService:
+    async def save(start: datetime, end: datetime):
+        ...
+
+
+
+
from collections.abc import Callable
+from datetime import datetime
+
+from asgikit.requests import Request
+from selva.di import Inject
+from selva.web.middleware import Middleware
+
+from application.service import TimingService
+
+
+class TimingMiddleware(Middleware):
+    timing_service: TimingService = Inject()
+
+    async def __call__(self, chain: Callable, request: Request):
+        request_start = datetime.now()
+        await chain(request)
+        request_end = datetime.now()
+
+        await self.timing_service.save(request_start, request_end)
+
+
+
+
+ + + + + + +
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/routing/index.html b/routing/index.html new file mode 100644 index 0000000..698f01c --- /dev/null +++ b/routing/index.html @@ -0,0 +1,621 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Routing - Selva + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + +

Routing

+

Routing is defined by the decorators in the controllers and handlers.

+

Path parameters

+

Parameters can be defined in the handler's path using the syntax :parameter_name, +where parameter_name must be the name of the argument on the handler's signature.

+
from typing import Annotated
+from asgikit.requests import Request
+from asgikit.responses import respond_text
+from selva.web import controller, get, FromPath
+
+
+@controller
+class Controller:
+    @get("hello/:name")
+    async def handler(self, request: Request, name: Annotated[str, FromPath]):
+        await respond_text(request.response, f"Hello, {name}!")
+
+

Here was used Annotated and FromPath to indicated that the handler argument +is to be bound to the parameter from the request path. More on that will be explained +in the following sections.

+

Path matching

+

The default behavior is for a path parameter to match a single path segment. +If you want to match the whole path, or a subpath of the request path, +use the syntax *parameter_name.

+
from typing import Annotated
+from asgikit.requests import Request
+from asgikit.responses import respond_text
+from selva.web import controller, get, FromPath
+
+
+@controller
+class Controller:
+    @get("hello/*path")
+    async def handler(self, request: Request, path: Annotated[str, FromPath]):
+        name = " ".join(path.split("/"))
+        await respond_text(request.response, f"Hello, {name}!")
+
+

For a request like GET hello/Python/World, the handler will output +Hello, Python World!.

+

You can mix both types of parameters with no problem:

+
    +
  • *path
  • +
  • *path/literal_segment
  • +
  • :normal_param/*path
  • +
  • :normal_param/*path/:other_path
  • +
+

Parameter conversion

+

Parameter conversion is done through the type annotation on the parameter. Selva +will try to find a converter suitable for the parameter type and then convert +the value before calling the handler method.

+
from typing import Annotated
+from asgikit.requests import Request
+from asgikit.responses import respond_json
+from selva.web import controller, get, FromPath
+
+
+@controller
+class Controller:
+    @get("repeat/:amount")
+    async def handler(self, request: Request, amount: Annotated[int, FromPath]):
+        await respond_json(request.response, {f"repeat {i}": i for i in range(amount)})
+
+

The type annotation indicates that we want a value of type int that should be +taken from the request path.

+

Selva already provide converters for the types str, int, float, bool and pathlib.PurePath.

+

Custom parameter conversion

+

Conversion can be customized by providing an implementing of selva.web.converter.param_converter.ParamConverter. +You normally use the shortcut decorator selva.web.converter.decodator.register_param_converter.

+
from dataclasses import dataclass
+from typing import Annotated
+
+from asgikit.requests import Request
+from asgikit.responses import respond_text
+from selva.web import controller, get, FromPath
+from selva.web.converter.decorator import register_param_converter
+
+
+@dataclass
+class MyModel:
+    name: str
+
+
+@register_param_converter(MyModel)
+class MyModelParamConverter:
+    def from_str(self, value: str) -> MyModel:
+        return MyModel(value)
+
+
+@controller
+class MyController:
+    @get("/:model")
+    async def handler(self, request: Request, model: Annotated[MyModel, FromPath]):
+        await respond_text(request.response, str(model))
+
+

If the ParamConverter implementation raise an error, the handler is not called. +And if the error is a subclass of selva.web.error.HTTPError, for instance +UnathorizedError, a response will be produced according to the error.

+

The ParamConverter can also be provided a method called into_str(self, obj) -> str +that is used to convert the object back. This is used to build urls from routes. +If not implemented, the default calls str on the object.

+ + + + + + +
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/search/search_index.json b/search/search_index.json new file mode 100644 index 0000000..b7dd9c8 --- /dev/null +++ b/search/search_index.json @@ -0,0 +1 @@ +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"Selva","text":"

Selva is a tool for creating ASGI applications that are easy to build and maintain.

It is built on top of asgikit and comes with a dependency injection system built upon Python type annotations. It is compatible with python 3.11+.

"},{"location":"#quickstart","title":"Quickstart","text":"

Install selva and uvicorn:

pip install selva uvicorn[standard]\n

Create file application.py:

from asgikit.requests import Request\nfrom asgikit.responses import respond_text\nfrom selva.web import controller, get\n@controller\nclass Controller:\n@get\nasync def hello(self, request: Request):\nawait respond_text(request.response, \"Hello, World\")\n

Run application with uvicorn. Selva will automatically load application.py:

uvicorn selva.run:app\n
INFO:     Started server process [18664]\nINFO:     Waiting for application startup.\nINFO:     Application startup complete.\nINFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)\n
"},{"location":"configuration/","title":"Configuration","text":"

Settings in Selva are handled through YAML files. Internally it uses strictyaml to parse the yaml files in order to do the parsing in a safe and predictable way.

Settings files are located by default in the configuration directory with the base name settings.yaml:

project/\n\u251c\u2500\u2500 application/\n\u2502   \u2514\u2500\u2500 ...\n\u2514\u2500\u2500 configuration/\n    \u251c\u2500\u2500 settings.yaml\n    \u251c\u2500\u2500 settings_dev.yaml\n    \u2514\u2500\u2500 settings_prod.yaml\n
"},{"location":"configuration/#accessing-the-configuration","title":"Accessing the configuration","text":"

The configuration values can be accessed by injecting selva.configuration.Settings.

from typing import Annotated\nfrom selva.configuration import Settings\nfrom selva.di import Inject, service\n@service\nclass MyService:\nsettings: Annotated[Settings, Inject]\n

The selva.configuration.Settings is a dict like object that can also be accessed using property syntax:

from selva.configuration import Settings\nsettings = Settings({\"config\": \"value\"})\nassert settings[\"config\"] == \"value\"\nassert settings.config == \"value\"\n
"},{"location":"configuration/#typed-settings","title":"Typed settings","text":"

Since strictyaml is used to parse the yaml files, all values strs. However, we can use pydantic and Selva dependency injection system to provide access to the settings in a typed manner:

application.pyconfiguration/settings.yaml
from pydantic import BaseModel\nfrom selva.configuration import Settings\nfrom selva.di import service\nclass MySettings(BaseModel):\nint_property: int\nbool_property: bool\n@service\ndef my_settings(settings: Settings) -> MySettings:\nreturn MySettings.model_validate(settings.my_settings)\n
my_settings:\nint_property: 1\nbool_property: true\n
"},{"location":"configuration/#environment-substitution","title":"Environment substitution","text":"

The settings files can include references to environment variables that takes the format ${ENV_VAR:default_value}. The default value is optional and an error will be raised if neither the environment variable nor the default value are defined.

required: ${ENV_VAR}         # required environment variable\noptional: ${OPT_VAR:default} # optional environment variable\n
"},{"location":"configuration/#profiles","title":"Profiles","text":"

Optional profiles can be activated by settings the environment variable SELVA_PROFILE. The framework will look for a file named settings_${SELVA_PROFILE}.yaml and merge the values with the main settings.yaml. Values from the profile settings take precedence over the values from the main settings.

As an example, if we define SELVA_PROFILE=dev, the file settings_dev.yaml will be loaded. If instead we define SELVA_PROFILE=prod, then the file settings_prod.yaml will be loaded.

"},{"location":"configuration/#environment-variables","title":"Environment variables","text":"

Settings can also be defined with environment variables whose names start with SELVA__, where subsequent double undercores (__) indicates nesting (variable is a mapping). Also, variable names will be lowercased.

For example, consider the following environment variables:

SELVA__PROPERTY=1\nSELVA__MAPPING__PROPERTY=2\nSELVA__MAPPING__ANOTHER_PROPERTY=3\n

Those variables will be collected as the following:

{\n\"property\": \"1\",\n\"mapping\": {\n\"property\": \"2\",\n\"another_property\": \"3\",\n},\n}\n
"},{"location":"configuration/#dotenv","title":"DotEnv","text":"

If running you project using selva.run.app, for example uvicorn selva.run:app, environment variables can be loaded from a .env file. The parsing is done using the python-dotenv library.

By default, a .env file in the current working directory will be loaded, but it can be customized with the environment variable SELVA_DOTENV pointing to a .env file.

"},{"location":"controllers/","title":"Controllers","text":""},{"location":"controllers/#overview","title":"Overview","text":"

Controllers are classes responsible for handling requests through handler methods. They are defined using the @controller on the class and @get, @post, @put, @patch, @delete and @websocket on each of the handler methods.

Handler methods must receive, at least, two parameters: Request and Response. It is not needed to annotate the request and response parameters, but they should be the first two parameters.

from asgikit.requests import Request, read_json\nfrom asgikit.responses import respond_text, respond_redirect\nfrom selva.web import controller, get, post\nfrom loguru import logger\n@controller\nclass IndexController:\n@get\nasync def index(self, request: Request):\nawait respond_text(request.response, \"application root\")\n@controller(\"admin\")\nclass AdminController:\n@post(\"send\")\nasync def handle_data(self, request: Request):\nlogger.info(await read_json(request))\nawait respond_redirect(request.response, \"/\")\n

Note

Defining a path on @controller or @get @post etc... is optional and defaults to an empty string \"\".

Handler methods can be defined with path parameters, which can be bound to the handler with the annotation FromPath:

from typing import Annotated\nfrom selva.web.converter import FromPath\nfrom selva.web.routing.decorator import get\n@get(\"/:path_param\")\ndef handler(request, path_param: Annotated[str, FromPath]):\n...\n

It is also possible to explicitly declare from which parameter the value will be retrieved from:

@get(\"/:path_param\")\ndef handler(req, res, value: Annotated[str, FromPath(\"path_param\")]):\n...\n

The routing section provides more information about path parameters

"},{"location":"controllers/#dependencies","title":"Dependencies","text":"

Controllers themselves are services, and therefore can have services injected.

from typing import Annotated\nfrom selva.di import service, Inject\nfrom selva.web import controller\n@service\nclass MyService:\npass\n@controller\nclass MyController:\nmy_service: Annotated[MyService, Inject]\n
"},{"location":"controllers/#request-information","title":"Request Information","text":"

Handler methods receive an object of type asgikit.requests.Request as the first parameter that provides access to request information (path, method, headers, query string, request body). It also provides the asgikit.responses.Response or asgikit.websockets.WebSocket objects to either respond the request or interact with the client via websocket.

Attention

For http requests, Request.websocket will be None, and for websocket requests, Request.response will be None

from http import HTTPMethod, HTTPStatus\nfrom asgikit.requests import Request\nfrom asgikit.responses import respond_json\nfrom selva.web import controller, get, websocket\n@controller\nclass MyController:\n@get\nasync def handler(self, request: Request):\nassert request.response is not None\nassert request.websocket is None\nassert request.method == HTTPMethod.GET\nassert request.path == \"/\"\nawait respond_json(request.response, {\"status\": HTTPStatus.OK})\n@websocket\nasync def ws_handler(self, request: Request):\nassert request.response is None\nassert request.websocket is not None\nws = request.websocket\nawait ws.accept()\nwhile True:\ndata = await ws.receive()\nawait ws.send(data)\n
"},{"location":"controllers/#request-body","title":"Request body","text":"

asgikit provides several functions to retrieve the request body:

async def read_body(request: Request) -> bytes\nasync def read_text(request: Request, encoding: str = None) -> str\nasync def read_json(request: Request) -> dict | list\nasync def read_form(request: Request) -> dict[str, str | multipart.File]:\n
"},{"location":"controllers/#websockets","title":"Websockets","text":"

For websocket, there are the following methods:

async def accept(subprotocol: str = None, headers: Iterable[tuple[bytes, bytes]] = None)\nasync def receive(self) -> str | bytes\nasync def send(self, data: bytes | str)\nasync def close(self, code: int = 1000, reason: str = \"\")\n
"},{"location":"controllers/#request-parameters","title":"Request Parameters","text":"

Handler methods can receive additional parameters, which will be extracted from the request using an implementation of selva.web.FromRequest[Type]. If there is no direct implementation of FromRequest[Type], Selva will iterate over the base types of Type until an implementation is found or an error will be returned if there is none.

You can use the register_from_request decorator to register an FromRequest implementation.

from asgikit.requests import Request\nfrom asgikit.responses import respond_text\nfrom selva.web import controller, get\nfrom selva.web.converter.decorator import register_from_request\nclass Param:\ndef __init__(self, path: str):\nself.request_path = path\n@register_from_request(Param)\nclass ParamFromRequest:\ndef from_request(\nself,\nrequest: Request,\noriginal_type,\nparameter_name,\nmetadata = None,\n) -> Param:\nreturn Param(request.path)\n@controller\nclass MyController:\n@get\nasync def handler(self, request: Request, param: Param):\nawait respond_text(request.response, param.request_path)\n

If the FromRequest implementation raise an error, the handler is not called. And if the error is a subclass of selva.web.error.HTTPError, for instance UnathorizedError, a response will be produced according to the error.

from selva.web.exception import HTTPUnauthorizedException\n@register_from_request(Param)\nclass ParamFromRequest:\ndef from_request(\nself,\nrequest: Request,\noriginal_type,\nparameter_name,\nmetadata = None,\n) -> Param:\nif \"authorization\" not in request.headers:\nraise HTTPUnauthorizedException()\nreturn Param(context.path)\n
"},{"location":"controllers/#pydantic","title":"Pydantic","text":"

Selva already implements FromRequest[pydantic.BaseModel] by reading the request body and parsing the input into the pydantic model, if the content type is json or form, otherwise raising an HTTPError with status code 415. It is also implemented for list[pydantic.BaseModel].

"},{"location":"controllers/#responses","title":"Responses","text":"

Inheriting the asgikit.responses.Response from asgikit, the handler methods do not return a response, instead they write to the response.

from asgikit.requests import Request\nfrom asgikit.responses import respond_text\nfrom selva.web import controller, get\n@controller\nclass Controller:\n@get\nasync def handler(self, request: Request):\nawait respond_text(request.response, \"Ok\")\n
"},{"location":"logging/","title":"Logging","text":"

Selva uses loguru for logging, but provides some facilities on top of it to make its usage a bit closer to other frameworks like Spring Boot.

First, an interceptor to the standard logging module is configured by default, as suggested in https://github.com/Delgan/loguru#entirely-compatible-with-standard-logging.

Second, a custom logging filter is provided in order to set the logging level for each package independently.

"},{"location":"logging/#configuring-logging","title":"Configuring logging","text":"

Logging is configured in the Selva configuration:

logging:\nroot: WARNING\nlevel:\napplication: INFO\napplication.service: TRACE\nsqlalchemy: DEBUG\nenable:\n- packages_to_activate_logging\ndisabled:\n- packages_to_deactivate_logging\n

The root property is the root level. It is used if no other level is set for the package where the log comes from.

The level property defines the logging level for each package independently.

The enable and disable properties lists the packages to enable or disable logging. This comes from loguru, as can be seen in https://github.com/Delgan/loguru#suitable-for-scripts-and-libraries.

"},{"location":"logging/#manual-logger-setup","title":"Manual logger setup","text":"

If you want full control of how loguru is configured, you can provide a logger setup function and reference it in the configuration file:

application/logging.pyconfiguration/settings.yaml
from loguru import logger\ndef setup(settings):\nlogger.configure(...)\n
logging:\nsetup: application.logging.setup\n

The setup function receives a parameter of type selva.configuration.Settings, so you can have access to the whole settings.

"},{"location":"middleware/","title":"Middleware","text":"

The middleware pipeline is configured with the MIDDLEWARE configuration property. It must contain a list of classes that inherit from selva.web.middleware.Middleware.

"},{"location":"middleware/#usage","title":"Usage","text":"

To demonstrate the middleware system, we will create a timing middleware that will output to the console the time spent in the processing of the request:

application/controller.pyapplication/middleware.pyconfiguration/settings.yaml
from asgikit.requests import Request\nfrom asgikit.responses import respond_json\nfrom selva.web import controller, get\n@controller\nclass HelloController:\n@get\nasync def hello(self, request: Request):\nawait respond_json(request.response, {\"greeting\": \"Hello, World!\"})\n
from collections.abc import Callable\nfrom datetime import datetime\nfrom asgikit.requests import Request\nfrom selva.web.middleware import Middleware\nfrom loguru import logger\nclass TimingMiddleware(Middleware):\nasync def __call__(self, chain: Callable, request: Request):\nrequest_start = datetime.now()\nawait chain(request) # (1)\nrequest_end = datetime.now()\ndelta = request_end - request_start\nlogger.info(\"Request time: {}\", delta)\n
  1. Invoke the middleware chain to process the request
middleware:\n- application.middleware.TimingMiddleware\n
"},{"location":"middleware/#middleware-dependencies","title":"Middleware dependencies","text":"

Middleware instances are created using the same machinery as services, and therefore can have services of their own. Our TimingMiddleware, for instance, could persist the timings using a service instead of printing to the console:

application/service.pyapplication/middleware.py
from datetime import datetime\nfrom selva.di import service\n@service\nclass TimingService:\nasync def save(start: datetime, end: datetime):\n...\n
from collections.abc import Callable\nfrom datetime import datetime\nfrom asgikit.requests import Request\nfrom selva.di import Inject\nfrom selva.web.middleware import Middleware\nfrom application.service import TimingService\nclass TimingMiddleware(Middleware):\ntiming_service: TimingService = Inject()\nasync def __call__(self, chain: Callable, request: Request):\nrequest_start = datetime.now()\nawait chain(request)\nrequest_end = datetime.now()\nawait self.timing_service.save(request_start, request_end)\n
"},{"location":"routing/","title":"Routing","text":"

Routing is defined by the decorators in the controllers and handlers.

"},{"location":"routing/#path-parameters","title":"Path parameters","text":"

Parameters can be defined in the handler's path using the syntax :parameter_name, where parameter_name must be the name of the argument on the handler's signature.

from typing import Annotated\nfrom asgikit.requests import Request\nfrom asgikit.responses import respond_text\nfrom selva.web import controller, get, FromPath\n@controller\nclass Controller:\n@get(\"hello/:name\")\nasync def handler(self, request: Request, name: Annotated[str, FromPath]):\nawait respond_text(request.response, f\"Hello, {name}!\")\n

Here was used Annotated and FromPath to indicated that the handler argument is to be bound to the parameter from the request path. More on that will be explained in the following sections.

"},{"location":"routing/#path-matching","title":"Path matching","text":"

The default behavior is for a path parameter to match a single path segment. If you want to match the whole path, or a subpath of the request path, use the syntax *parameter_name.

from typing import Annotated\nfrom asgikit.requests import Request\nfrom asgikit.responses import respond_text\nfrom selva.web import controller, get, FromPath\n@controller\nclass Controller:\n@get(\"hello/*path\")\nasync def handler(self, request: Request, path: Annotated[str, FromPath]):\nname = \" \".join(path.split(\"/\"))\nawait respond_text(request.response, f\"Hello, {name}!\")\n

For a request like GET hello/Python/World, the handler will output Hello, Python World!.

You can mix both types of parameters with no problem:

  • *path
  • *path/literal_segment
  • :normal_param/*path
  • :normal_param/*path/:other_path
"},{"location":"routing/#parameter-conversion","title":"Parameter conversion","text":"

Parameter conversion is done through the type annotation on the parameter. Selva will try to find a converter suitable for the parameter type and then convert the value before calling the handler method.

from typing import Annotated\nfrom asgikit.requests import Request\nfrom asgikit.responses import respond_json\nfrom selva.web import controller, get, FromPath\n@controller\nclass Controller:\n@get(\"repeat/:amount\")\nasync def handler(self, request: Request, amount: Annotated[int, FromPath]):\nawait respond_json(request.response, {f\"repeat {i}\": i for i in range(amount)})\n

The type annotation indicates that we want a value of type int that should be taken from the request path.

Selva already provide converters for the types str, int, float, bool and pathlib.PurePath.

"},{"location":"routing/#custom-parameter-conversion","title":"Custom parameter conversion","text":"

Conversion can be customized by providing an implementing of selva.web.converter.param_converter.ParamConverter. You normally use the shortcut decorator selva.web.converter.decodator.register_param_converter.

from dataclasses import dataclass\nfrom typing import Annotated\nfrom asgikit.requests import Request\nfrom asgikit.responses import respond_text\nfrom selva.web import controller, get, FromPath\nfrom selva.web.converter.decorator import register_param_converter\n@dataclass\nclass MyModel:\nname: str\n@register_param_converter(MyModel)\nclass MyModelParamConverter:\ndef from_str(self, value: str) -> MyModel:\nreturn MyModel(value)\n@controller\nclass MyController:\n@get(\"/:model\")\nasync def handler(self, request: Request, model: Annotated[MyModel, FromPath]):\nawait respond_text(request.response, str(model))\n

If the ParamConverter implementation raise an error, the handler is not called. And if the error is a subclass of selva.web.error.HTTPError, for instance UnathorizedError, a response will be produced according to the error.

The ParamConverter can also be provided a method called into_str(self, obj) -> str that is used to convert the object back. This is used to build urls from routes. If not implemented, the default calls str on the object.

"},{"location":"tutorial/","title":"Tutorial","text":"

Let's dig a little deeper and learn the basic concepts of Selva.

We will create a greeting api that logs the greet requests.

"},{"location":"tutorial/#installing-selva","title":"Installing Selva","text":"

Before going any further, we need to install Selva and Uvicorn.

pip install selva uvicorn\n
"},{"location":"tutorial/#structure-of-the-application","title":"Structure of the application","text":"

A selva application is structured like the following:

project/\n\u251c\u2500\u2500 application/\n\u2502   \u251c\u2500\u2500 __init__.py\n\u2502   \u251c\u2500\u2500 controller.py\n\u2502   \u251c\u2500\u2500 repository.py\n\u2502   \u2514\u2500\u2500 service.py\n\u251c\u2500\u2500 configuration/\n\u2502   \u2514\u2500\u2500 settings.yaml\n\u2514\u2500\u2500 resources/\n

And... that's it! A module or package named application will automatically be imported and scanned for controllers and services.

"},{"location":"tutorial/#running-the-application","title":"Running the application","text":"

We will use uvicorn to run the application and automatically reload when we make changes to the code:

$ uvicorn selva.run:app --reload\nINFO:     Will watch for changes in these directories: ['/home/user/projects/selva-tutorial']\nINFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)\nINFO:     Started reloader process [23568] using WatchFiles\nINFO:     Started server process [19472]\nINFO:     Waiting for application startup.\nINFO:     Application startup complete.\n
"},{"location":"tutorial/#creating-the-greetingcontroller","title":"Creating the GreetingController","text":"

Controller classes hold handler methods that will respond to HTTP or WebSocket requests. They can receive services through dependency injection.

application/controller.py
from typing import Annotated\nfrom asgikit.requests import Request\nfrom asgikit.responses import respond_json\nfrom selva.web import controller, get, FromPath\n@controller # (1)\nclass GreetingController:\n@get(\"hello/:name\") # (2)\nasync def hello(self, request: Request, name: Annotated[str, FromPath]):\nawait respond_json(request.response, {\"greeting\": f\"Hello, {name}!\"})\n
  1. @controller marks a class as a controller. It can optionally receive a path (e.g. @controller(\"path\")) that will be prepended to the handlers' path.

  2. @get(\"hello/:name\") defines the method as a handler on the given path. If no path is given, the path from the controller will be used.

    :name defines a path parameter that will be bound to the name parameter on the handler, indicated by Annotated[str, FromPath]

And now we test if our controller is working:

$ curl localhost:8000/hello/World\n{\"greeting\": \"Hello, World!\"}\n

Right now our controller just get a name from the query string and return a dict. When a handler returns a dict or a list it will be automatically converted to JSON.

"},{"location":"tutorial/#creating-the-greeter-service","title":"Creating the Greeter service","text":"

Our service will have a method that receives a name and returns a greeting. It will be injected into the controller we created previously.

/application/service.pyapplication/controller.py
from selva.di import service\n@service # (1)\nclass Greeter:\ndef greet(self, name: str) -> str:\nreturn f\"Hello, {name}!\"\n
  1. @service registers the class in the dependency injection system so it can be injected in other classes
from typing import Annotated\nfrom asgikit.requests import Request\nfrom asgikit.responses import respond_json\nfrom selva.di import Inject\nfrom selva.web import controller, get\nfrom .service import Greeter\n@controller\nclass GreetingController:\ngretter: Annotated[Gretter, Inject] # (1)\n@get(\"/hello/:name\")\nasync def hello(self, request: Request, name: Annotated[str, FromPath]):\ngreeting = self.greeter.greet(name)\nawait respond_json(request.response, {\"greeting\": greeting})\n
  1. Inject the Greeter service
"},{"location":"tutorial/#adding-a-database","title":"Adding a database","text":"

Our greeting application is working fine, but we might want to add register the greeting requests in a persistent database, for auditing purposes.

To do this we need to create the database service and inject it into the Greeter service. For this we can use the Databases library with SQLite support:

pip install databases[aiosqlite]\n

Databases provides a class called Database. However, we can not decorate it with @service, so in this case we need to create a factory function for it:

application/repository.pyapplication/controller.py
from datetime import datetime\nfrom typing import Annotated\nfrom databases import Database\nfrom selva.di import service, Inject\n@service # (1)\nasync def database_factory() -> Database:\ndatabase = Database(\"sqlite:///database.sqlite3\")\nquery = \"\"\"\n        create table if not exists greeting_log(\n            greeting text not null,\n            datetime text not null\n        );\n    \"\"\"\nawait database.execute(query)\nreturn database\n@service\nclass GreetingRepository:\ndatabase: Annotated[Database, Inject] # (2)\nasync def initialize(self): # (3)\nawait self.database.connect()\nasync def finalize(self): # (4)\nawait self.database.disconnect()\nasync def save_greeting(self, greeting: str, date: datetime):\nquery = \"\"\"\n            insert into greeting_log (greeting, datetime)\n            values (:greeting, datetime(:datetime))\n        \"\"\"\nparams = {\"greeting\": greeting, \"datetime\": date}\nawait self.database.execute(query, params)\n
  1. A function decorated with @service is used to create a service when you need to provide types you do not own

  2. Inject the Database service in the GreetingRepository

  3. A method called initialize will be invoked after the service is constructed in order to run any initialization logic

  4. A method called finalize will be invoked before the service is destroyed in order to run any cleanup logic

from typing import Annotated\nfrom datetime import datetime\nfrom asgikit.requests import Request\nfrom asgikit.responses import respond_json\nfrom selva.di import Inject\nfrom selva.web import controller, get, FromPath\nfrom .repository import GreetingRepository\nfrom .service import Greeter\n@controller\nclass GreetingController:\ngreeter: Annotated[Greeter, Inject]\nrepository: Annotated[GreetingRepository, Inject]\n@get(\"hello/:name\")\nasync def hello_name(self, request: Request, name: Annotated[str, FromPath]):\ngreeting = self.greeter.greet(name)\nawait self.repository.save_greeting(greeting, datetime.now())\nawait respond_json(request.response, {\"greeting\": greeting})\n
"},{"location":"tutorial/#execute-actions-after-response","title":"Execute actions after response","text":"

The greetings are being saved to the database, but now we have a problem: the user has to wait until the greeting is saved before receiving it.

To solve this problem and improve the user experience, we can use save the greeging after the request is completed:

application/controller.py
from datetime import datetime\nfrom typing import Annotated\nfrom asgikit.requests import Request\nfrom asgikit.responses improt respond_json\nfrom selva.di import Inject\nfrom selva.web import controller, get, FromPath\nfrom .repository import GreetingRepository\nfrom .service import Greeter\n@controller\nclass GreetingController:\ngreeter: Annotated[Greeter, Inject]\nrepository: Annotated[GreetingRepository, Inject]\n@get(\"hello/:name\")\nasync def hello_name(self, request: Request, name: Annotated[str, FromPath]):\ngreeting = self.greeter.greet(name)\nawait respond_json(request.response, {\"greeting\": greeting})  # (1)\nawait self.repository.save_greeting(greeting, datetime.now())  # (2)\n
  1. The call to respond_json completes the response

  2. The greeting is saved after the response is completed

"},{"location":"tutorial/#retrieving-the-greeting-logs","title":"Retrieving the greeting logs","text":"

To see the greetings saved to the database, we just need to add a route to get the logs and return them:

application/repository.pyapplication/controllers.py
@service\nclass GreetingRepository:\n# ...\nasync def get_greetings(self) -> list[tuple[str, str]]:\nquery = \"\"\"\n            select l.greeting, datetime(l.datetime) from greeting_log l\n            order by rowid desc\n        \"\"\"\nresult = await self.database.fetch_all(query)\nreturn [{\"greeting\": r.greeting, \"datetime\": r.datetime} for r in result]\n
@controller\nclass GreetingController:\n# ...\n@get(\"/logs\")\nasync def greeting_logs(self, request: Request):\ngreetings = await self.repository.get_greetings()\nawait respond_json(request.response, greetings)\n

Now let us try requesting some greetings and retrieving the logs:

$ curl localhost:8000/hello/Python\n{\"greeting\": \"Hello, Python!\"}\n$ curl localhost:8000/hello/World\n{\"greeting\": \"Hello, World!\"}\n$ curl -s localhost:8000/logs | python -m json.tool\n[\n{\n\"greeting\": \"Hello, World!\",\n        \"datetime\": \"2022-07-06 14:23:14\"\n},\n    {\n\"greeting\": \"Hello, Python!\",\n        \"datetime\": \"2022-07-06 14:23:08\"\n},\n]\n
"},{"location":"tutorial/#receiving-post-data","title":"Receiving post data","text":"

We can also send the name in the body of the request, instead of the url, and use Pydantic to parse the request body:

application/models.pyapplication/controller.py
from pydantic import BaseModel\nclass GreetingRequest(BaseModel):\nname: str\n
# ...\nfrom .model import GreetingRequest\n@controller\nclass GreetingController:\ngreeter: Annotated[Greeter, Inject]\nrepository: Annotated[GreetingRepository, Inject]\n# ...\n@post(\"hello\")\nasync def hello_post(self, request: Request, greeting_request: GreetingRequest):\nname = greeting_request.name\ngreeting = self.greeter.greet(name)\nawait respond_json(request.response, {\"greeting\": greeting})\nawait self.repository.save_greeting(greeting, datetime.now())\n

And to test it:

$ curl -H 'Content-Type: application/json' -d '{\"name\": \"World\"}' localhost:8000/hello\n{\"greeting\": \"Hello, World!\"}\n
"}]} \ No newline at end of file diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 0000000..9e63955 --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,38 @@ + + + + https://livioribeiro.github.io/selva/ + 2023-10-31 + daily + + + https://livioribeiro.github.io/selva/configuration/ + 2023-10-31 + daily + + + https://livioribeiro.github.io/selva/controllers/ + 2023-10-31 + daily + + + https://livioribeiro.github.io/selva/logging/ + 2023-10-31 + daily + + + https://livioribeiro.github.io/selva/middleware/ + 2023-10-31 + daily + + + https://livioribeiro.github.io/selva/routing/ + 2023-10-31 + daily + + + https://livioribeiro.github.io/selva/tutorial/ + 2023-10-31 + daily + + \ No newline at end of file diff --git a/sitemap.xml.gz b/sitemap.xml.gz new file mode 100644 index 0000000..dc3f802 Binary files /dev/null and b/sitemap.xml.gz differ diff --git a/tutorial/index.html b/tutorial/index.html new file mode 100644 index 0000000..8f0b195 --- /dev/null +++ b/tutorial/index.html @@ -0,0 +1,927 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Tutorial - Selva + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + +
+
+
+ + + + + + + +
+
+ + + + +

Tutorial

+

Let's dig a little deeper and learn the basic concepts of Selva.

+

We will create a greeting api that logs the greet requests.

+

Installing Selva

+

Before going any further, we need to install Selva and Uvicorn.

+
pip install selva uvicorn
+
+

Structure of the application

+

A selva application is structured like the following:

+
project/
+├── application/
+│   ├── __init__.py
+│   ├── controller.py
+│   ├── repository.py
+│   └── service.py
+├── configuration/
+│   └── settings.yaml
+└── resources/
+
+

And... that's it! A module or package named application will automatically +be imported and scanned for controllers and services.

+

Running the application

+

We will use uvicorn to run the application and automatically reload when we +make changes to the code:

+
$ uvicorn selva.run:app --reload
+INFO:     Will watch for changes in these directories: ['/home/user/projects/selva-tutorial']
+INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
+INFO:     Started reloader process [23568] using WatchFiles
+INFO:     Started server process [19472]
+INFO:     Waiting for application startup.
+INFO:     Application startup complete.
+
+

Creating the GreetingController

+

Controller classes hold handler methods that will respond to HTTP or WebSocket +requests. They can receive services through dependency injection.

+
+
+
+
from typing import Annotated
+from asgikit.requests import Request
+from asgikit.responses import respond_json
+from selva.web import controller, get, FromPath
+
+
+@controller # (1)
+class GreetingController:
+    @get("hello/:name") # (2)
+    async def hello(self, request: Request, name: Annotated[str, FromPath]):
+        await respond_json(request.response, {"greeting": f"Hello, {name}!"})
+
+
    +
  1. +

    @controller marks a class as a controller. It can optionally receive + a path (e.g. @controller("path")) that will be prepended to the handlers' path.

    +
  2. +
  3. +

    @get("hello/:name") defines the method as a handler on the given path. + If no path is given, the path from the controller will be used.

    +

    :name defines a path parameter that will be bound to the name +parameter on the handler, indicated by Annotated[str, FromPath]

    +
  4. +
+
+
+
+

And now we test if our controller is working:

+
$ curl localhost:8000/hello/World
+{"greeting": "Hello, World!"}
+
+

Right now our controller just get a name from the query string and return a +dict. When a handler returns a dict or a list it will be automatically +converted to JSON.

+

Creating the Greeter service

+

Our service will have a method that receives a name and returns a greeting. It +will be injected into the controller we created previously.

+
+
+
+
from selva.di import service
+
+
+@service # (1)
+class Greeter:
+    def greet(self, name: str) -> str:
+        return f"Hello, {name}!"
+
+
    +
  1. @service registers the class in the dependency injection system so it + can be injected in other classes
  2. +
+
+
+
from typing import Annotated
+from asgikit.requests import Request
+from asgikit.responses import respond_json
+from selva.di import Inject
+from selva.web import controller, get
+from .service import Greeter
+
+
+@controller
+class GreetingController:
+    gretter: Annotated[Gretter, Inject] # (1)
+
+    @get("/hello/:name")
+    async def hello(self, request: Request, name: Annotated[str, FromPath]):
+        greeting = self.greeter.greet(name)
+        await respond_json(request.response, {"greeting": greeting})
+
+
    +
  1. Inject the Greeter service
  2. +
+
+
+
+

Adding a database

+

Our greeting application is working fine, but we might want to add register +the greeting requests in a persistent database, for auditing purposes.

+

To do this we need to create the database service and inject it into the +Greeter service. For this we can use the Databases +library with SQLite support:

+
pip install databases[aiosqlite]
+
+

Databases provides a class called Database. However, we can not decorate it +with @service, so in this case we need to create a factory function for it:

+
+
+
+
from datetime import datetime
+from typing import Annotated
+from databases import Database
+from selva.di import service, Inject
+
+@service # (1)
+async def database_factory() -> Database:
+    database = Database("sqlite:///database.sqlite3")
+    query = """
+        create table if not exists greeting_log(
+            greeting text not null,
+            datetime text not null
+        );
+    """
+    await database.execute(query)
+    return database
+
+
+@service
+class GreetingRepository:
+    database: Annotated[Database, Inject] # (2)
+
+    async def initialize(self): # (3)
+        await self.database.connect()
+
+    async def finalize(self): # (4)
+        await self.database.disconnect()
+
+    async def save_greeting(self, greeting: str, date: datetime):
+        query = """
+            insert into greeting_log (greeting, datetime)
+            values (:greeting, datetime(:datetime))
+        """
+        params = {"greeting": greeting, "datetime": date}
+        await self.database.execute(query, params)
+
+
    +
  1. +

    A function decorated with @service is used to create a service when + you need to provide types you do not own

    +
  2. +
  3. +

    Inject the Database service in the GreetingRepository

    +
  4. +
  5. +

    A method called initialize will be invoked after the service is + constructed in order to run any initialization logic

    +
  6. +
  7. +

    A method called finalize will be invoked before the service is + destroyed in order to run any cleanup logic

    +
  8. +
+
+
+
from typing import Annotated
+from datetime import datetime
+from asgikit.requests import Request
+from asgikit.responses import respond_json
+from selva.di import Inject
+from selva.web import controller, get, FromPath
+from .repository import GreetingRepository
+from .service import Greeter
+
+
+@controller
+class GreetingController:
+    greeter: Annotated[Greeter, Inject]
+    repository: Annotated[GreetingRepository, Inject]
+
+    @get("hello/:name")
+    async def hello_name(self, request: Request, name: Annotated[str, FromPath]):
+        greeting = self.greeter.greet(name)
+        await self.repository.save_greeting(greeting, datetime.now())
+        await respond_json(request.response, {"greeting": greeting})
+
+
+
+
+

Execute actions after response

+

The greetings are being saved to the database, but now we have a problem: the +user has to wait until the greeting is saved before receiving it.

+

To solve this problem and improve the user experience, we can use save the greeging +after the request is completed:

+
+
+
+
from datetime import datetime
+from typing import Annotated
+from asgikit.requests import Request
+from asgikit.responses improt respond_json
+from selva.di import Inject
+from selva.web import controller, get, FromPath
+from .repository import GreetingRepository
+from .service import Greeter
+
+
+@controller
+class GreetingController:
+    greeter: Annotated[Greeter, Inject]
+    repository: Annotated[GreetingRepository, Inject]
+
+    @get("hello/:name")
+    async def hello_name(self, request: Request, name: Annotated[str, FromPath]):
+        greeting = self.greeter.greet(name)
+        await respond_json(request.response, {"greeting": greeting})  # (1)
+
+        await self.repository.save_greeting(greeting, datetime.now())  # (2)
+
+
    +
  1. +

    The call to respond_json completes the response

    +
  2. +
  3. +

    The greeting is saved after the response is completed

    +
  4. +
+
+
+
+

Retrieving the greeting logs

+

To see the greetings saved to the database, we just need to add a route to get +the logs and return them:

+
+
+
+
@service
+class GreetingRepository:
+    # ...
+    async def get_greetings(self) -> list[tuple[str, str]]:
+        query = """
+            select l.greeting, datetime(l.datetime) from greeting_log l
+            order by rowid desc
+        """
+        result = await self.database.fetch_all(query)
+        return [{"greeting": r.greeting, "datetime": r.datetime} for r in result]
+
+
+
+
@controller
+class GreetingController:
+    # ...
+    @get("/logs")
+    async def greeting_logs(self, request: Request):
+        greetings = await self.repository.get_greetings()
+        await respond_json(request.response, greetings)
+
+
+
+
+

Now let us try requesting some greetings and retrieving the logs:

+
$ curl localhost:8000/hello/Python
+{"greeting": "Hello, Python!"}
+
+$ curl localhost:8000/hello/World
+{"greeting": "Hello, World!"}
+
+$ curl -s localhost:8000/logs | python -m json.tool
+[
+    {
+        "greeting": "Hello, World!",
+        "datetime": "2022-07-06 14:23:14"
+    },
+    {
+        "greeting": "Hello, Python!",
+        "datetime": "2022-07-06 14:23:08"
+    },
+]
+
+

Receiving post data

+

We can also send the name in the body of the request, instead of the url, and +use Pydantic to parse the request body:

+
+
+
+
from pydantic import BaseModel
+
+
+class GreetingRequest(BaseModel):
+    name: str
+
+
+
+
# ...
+from .model import GreetingRequest
+
+
+@controller
+class GreetingController:
+    greeter: Annotated[Greeter, Inject]
+    repository: Annotated[GreetingRepository, Inject]
+
+    # ...
+
+    @post("hello")
+    async def hello_post(self, request: Request, greeting_request: GreetingRequest):
+        name = greeting_request.name
+        greeting = self.greeter.greet(name)
+        await respond_json(request.response, {"greeting": greeting})
+        await self.repository.save_greeting(greeting, datetime.now())
+
+
+
+
+

And to test it:

+
$ curl -H 'Content-Type: application/json' -d '{"name": "World"}' localhost:8000/hello
+{"greeting": "Hello, World!"}
+
+ + + + + + +
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file