0&&"constructor"==s[c-1]))throw new TypeError("JSON-Patch: modifying `__proto__` or `constructor/prototype` prop is banned for security reasons, if this was on purpose, please set `banPrototypeModifications` flag false and pass it to this function. More info in fast-json-patch README");if(n&&void 0===d&&(void 0===l[g]?d=s.slice(0,c).join("/"):c==p-1&&(d=t.path),void 0!==d&&m(t,0,e,d)),c++,Array.isArray(l)){if("-"===g)g=l.length;else{if(n&&!f(g))throw new v("Expected an unsigned base-10 integer value, making the new referenced value the array element with the zero-based index","OPERATION_PATH_ILLEGAL_ARRAY_INDEX",o,t,e);f(g)&&(g=~~g)}if(c>=p){if(n&&"add"===t.op&&g>l.length)throw new v("The specified index MUST NOT be greater than the number of elements in the array","OPERATION_VALUE_OUT_OF_BOUNDS",o,t,e);if(!1===(a=y[t.op].call(t,l,g,e)).test)throw new v("Test operation failed","TEST_OPERATION_FAILED",o,t,e);return a}}else if(c>=p){if(!1===(a=b[t.op].call(t,l,g,e)).test)throw new v("Test operation failed","TEST_OPERATION_FAILED",o,t,e);return a}if(l=l[g],n&&c0)throw new v('Operation `path` property must start with "/"',"OPERATION_PATH_INVALID",t,e,n);if(("move"===e.op||"copy"===e.op)&&"string"!=typeof e.from)throw new v("Operation `from` property is not present (applicable in `move` and `copy` operations)","OPERATION_FROM_REQUIRED",t,e,n);if(("add"===e.op||"replace"===e.op||"test"===e.op)&&void 0===e.value)throw new v("Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)","OPERATION_VALUE_REQUIRED",t,e,n);if(("add"===e.op||"replace"===e.op||"test"===e.op)&&d(e.value))throw new v("Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)","OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED",t,e,n);if(n)if("add"==e.op){var i=e.path.split("/").length,o=r.split("/").length;if(i!==o+1&&i!==o)throw new v("Cannot perform an `add` operation at the desired path","OPERATION_PATH_CANNOT_ADD",t,e,n)}else if("replace"===e.op||"remove"===e.op||"_get"===e.op){if(e.path!==r)throw new v("Cannot perform the operation at a path that does not exist","OPERATION_PATH_UNRESOLVABLE",t,e,n)}else if("move"===e.op||"copy"===e.op){var a=I([{op:"_get",path:e.from,value:void 0}],n);if(a&&"OPERATION_PATH_UNRESOLVABLE"===a.name)throw new v("Cannot perform the operation from a path that does not exist","OPERATION_FROM_UNRESOLVABLE",t,e,n)}}function I(e,t,n){try{if(!Array.isArray(e))throw new v("Patch sequence must be an array","SEQUENCE_NOT_AN_ARRAY");if(t)A(h(t),h(e),n||!0);else{n=n||x;for(var r=0;r0&&(e.patches=[],e.callback&&e.callback(r)),r}function D(e,t,n,r,i){if(t!==e){"function"==typeof t.toJSON&&(t=t.toJSON());for(var o=c(t),a=c(e),s=!1,f=a.length-1;f>=0;f--){var u=e[g=a[f]];if(!l(t,g)||void 0===t[g]&&void 0!==u&&!1===Array.isArray(t))Array.isArray(e)===Array.isArray(t)?(i&&n.push({op:"test",path:r+"/"+p(g),value:h(u)}),n.push({op:"remove",path:r+"/"+p(g)}),s=!0):(i&&n.push({op:"test",path:r,value:e}),n.push({op:"replace",path:r,value:t}));else{var d=t[g];"object"==typeof u&&null!=u&&"object"==typeof d&&null!=d&&Array.isArray(u)===Array.isArray(d)?D(u,d,n,r+"/"+p(g),i):u!==d&&(i&&n.push({op:"test",path:r+"/"+p(g),value:h(u)}),n.push({op:"replace",path:r+"/"+p(g),value:h(d)}))}}if(s||o.length!=a.length)for(f=0;f0)return[m,n+c.join(",\n"+d),s].join("\n"+o)}return v}(e,"",0)},j=F(M);var z=B;function B(e){var t=this;if(t instanceof B||(t=new B),t.tail=null,t.head=null,t.length=0,e&&"function"==typeof e.forEach)e.forEach((function(e){t.push(e)}));else if(arguments.length>0)for(var n=0,r=arguments.length;n1)n=t;else{if(!this.head)throw new TypeError("Reduce of empty list with no initial value");r=this.head.next,n=this.head.value}for(var i=0;null!==r;i++)n=e(n,r.value,i),r=r.next;return n},B.prototype.reduceReverse=function(e,t){var n,r=this.tail;if(arguments.length>1)n=t;else{if(!this.tail)throw new TypeError("Reduce of empty list with no initial value");r=this.tail.prev,n=this.tail.value}for(var i=this.length-1;null!==r;i--)n=e(n,r.value,i),r=r.prev;return n},B.prototype.toArray=function(){for(var e=new Array(this.length),t=0,n=this.head;null!==n;t++)e[t]=n.value,n=n.next;return e},B.prototype.toArrayReverse=function(){for(var e=new Array(this.length),t=0,n=this.tail;null!==n;t++)e[t]=n.value,n=n.prev;return e},B.prototype.slice=function(e,t){(t=t||this.length)<0&&(t+=this.length),(e=e||0)<0&&(e+=this.length);var n=new B;if(tthis.length&&(t=this.length);for(var r=0,i=this.head;null!==i&&rthis.length&&(t=this.length);for(var r=this.length,i=this.tail;null!==i&&r>t;r--)i=i.prev;for(;null!==i&&r>e;r--,i=i.prev)n.push(i.value);return n},B.prototype.splice=function(e,t,...n){e>this.length&&(e=this.length-1),e<0&&(e=this.length+e);for(var r=0,i=this.head;null!==i&&r1;const ie=(e,t,n)=>{const r=e[te].get(t);if(r){const t=r.value;if(oe(e,t)){if(se(e,r),!e[J])return}else n&&(e[ne]&&(r.value.now=Date.now()),e[ee].unshiftNode(r));return t.value}},oe=(e,t)=>{if(!t||!t.maxAge&&!e[Q])return!1;const n=Date.now()-t.now;return t.maxAge?n>t.maxAge:e[Q]&&n>e[Q]},ae=e=>{if(e[q]>e[H])for(let t=e[ee].tail;e[q]>e[H]&&null!==t;){const n=t.prev;se(e,t),t=n}},se=(e,t)=>{if(t){const n=t.value;e[Z]&&e[Z](n.key,n.value),e[q]-=n.length,e[te].delete(n.key),e[ee].removeNode(t)}};class le{constructor(e,t,n,r,i){this.key=e,this.value=t,this.length=n,this.now=r,this.maxAge=i||0}}const ce=(e,t,n,r)=>{let i=n.value;oe(e,i)&&(se(e,n),e[J]||(i=void 0)),i&&t.call(r,i.value,i.key,e)};var he=class{constructor(e){if("number"==typeof e&&(e={max:e}),e||(e={}),e.max&&("number"!=typeof e.max||e.max<0))throw new TypeError("max must be a non-negative number");this[H]=e.max||1/0;const t=e.length||re;if(this[Y]="function"!=typeof t?re:t,this[J]=e.stale||!1,e.maxAge&&"number"!=typeof e.maxAge)throw new TypeError("maxAge must be a number");this[Q]=e.maxAge||0,this[Z]=e.dispose,this[K]=e.noDisposeOnSet||!1,this[ne]=e.updateAgeOnGet||!1,this.reset()}set max(e){if("number"!=typeof e||e<0)throw new TypeError("max must be a non-negative number");this[H]=e||1/0,ae(this)}get max(){return this[H]}set allowStale(e){this[J]=!!e}get allowStale(){return this[J]}set maxAge(e){if("number"!=typeof e)throw new TypeError("maxAge must be a non-negative number");this[Q]=e,ae(this)}get maxAge(){return this[Q]}set lengthCalculator(e){"function"!=typeof e&&(e=re),e!==this[Y]&&(this[Y]=e,this[q]=0,this[ee].forEach((e=>{e.length=this[Y](e.value,e.key),this[q]+=e.length}))),ae(this)}get lengthCalculator(){return this[Y]}get length(){return this[q]}get itemCount(){return this[ee].length}rforEach(e,t){t=t||this;for(let n=this[ee].tail;null!==n;){const r=n.prev;ce(this,e,n,t),n=r}}forEach(e,t){t=t||this;for(let n=this[ee].head;null!==n;){const r=n.next;ce(this,e,n,t),n=r}}keys(){return this[ee].toArray().map((e=>e.key))}values(){return this[ee].toArray().map((e=>e.value))}reset(){this[Z]&&this[ee]&&this[ee].length&&this[ee].forEach((e=>this[Z](e.key,e.value))),this[te]=new Map,this[ee]=new V,this[q]=0}dump(){return this[ee].map((e=>!oe(this,e)&&{k:e.key,v:e.value,e:e.now+(e.maxAge||0)})).toArray().filter((e=>e))}dumpLru(){return this[ee]}set(e,t,n){if((n=n||this[Q])&&"number"!=typeof n)throw new TypeError("maxAge must be a number");const r=n?Date.now():0,i=this[Y](t,e);if(this[te].has(e)){if(i>this[H])return se(this,this[te].get(e)),!1;const o=this[te].get(e).value;return this[Z]&&(this[K]||this[Z](e,o.value)),o.now=r,o.maxAge=n,o.value=t,this[q]+=i-o.length,o.length=i,this.get(e),ae(this),!0}const o=new le(e,t,i,r,n);return o.length>this[H]?(this[Z]&&this[Z](e,t),!1):(this[q]+=o.length,this[ee].unshift(o),this[te].set(e,this[ee].head),ae(this),!0)}has(e){if(!this[te].has(e))return!1;const t=this[te].get(e).value;return!oe(this,t)}get(e){return ie(this,e,!0)}peek(e){return ie(this,e,!1)}pop(){const e=this[ee].tail;return e?(se(this,e),e.value):null}del(e){se(this,this[te].get(e))}load(e){this.reset();const t=Date.now();for(let n=e.length-1;n>=0;n--){const r=e[n],i=r.e||0;if(0===i)this.set(r.k,r.v);else{const e=i-t;e>0&&this.set(r.k,r.v,e)}}}prune(){this[te].forEach(((e,t)=>ie(this,t,!1)))}};const fe=Object.freeze({loose:!0}),pe=Object.freeze({});var ue=e=>e?"object"!=typeof e?fe:e:pe,de={exports:{}};var ge={MAX_LENGTH:256,MAX_SAFE_COMPONENT_LENGTH:16,MAX_SAFE_BUILD_LENGTH:250,MAX_SAFE_INTEGER:Number.MAX_SAFE_INTEGER||9007199254740991,RELEASE_TYPES:["major","premajor","minor","preminor","patch","prepatch","prerelease"],SEMVER_SPEC_VERSION:"2.0.0",FLAG_INCLUDE_PRERELEASE:1,FLAG_LOOSE:2};var me="object"==typeof process&&process.env&&process.env.NODE_DEBUG&&/\bsemver\b/i.test(process.env.NODE_DEBUG)?(...e)=>console.error("SEMVER",...e):()=>{};!function(e,t){const{MAX_SAFE_COMPONENT_LENGTH:n,MAX_SAFE_BUILD_LENGTH:r,MAX_LENGTH:i}=ge,o=me,a=(t=e.exports={}).re=[],s=t.safeRe=[],l=t.src=[],c=t.t={};let h=0;const f="[a-zA-Z0-9-]",p=[["\\s",1],["\\d",i],[f,r]],u=(e,t,n)=>{const r=(e=>{for(const[t,n]of p)e=e.split(`${t}*`).join(`${t}{0,${n}}`).split(`${t}+`).join(`${t}{1,${n}}`);return e})(t),i=h++;o(e,i,t),c[e]=i,l[i]=t,a[i]=new RegExp(t,n?"g":void 0),s[i]=new RegExp(r,n?"g":void 0)};u("NUMERICIDENTIFIER","0|[1-9]\\d*"),u("NUMERICIDENTIFIERLOOSE","\\d+"),u("NONNUMERICIDENTIFIER",`\\d*[a-zA-Z-]${f}*`),u("MAINVERSION",`(${l[c.NUMERICIDENTIFIER]})\\.(${l[c.NUMERICIDENTIFIER]})\\.(${l[c.NUMERICIDENTIFIER]})`),u("MAINVERSIONLOOSE",`(${l[c.NUMERICIDENTIFIERLOOSE]})\\.(${l[c.NUMERICIDENTIFIERLOOSE]})\\.(${l[c.NUMERICIDENTIFIERLOOSE]})`),u("PRERELEASEIDENTIFIER",`(?:${l[c.NUMERICIDENTIFIER]}|${l[c.NONNUMERICIDENTIFIER]})`),u("PRERELEASEIDENTIFIERLOOSE",`(?:${l[c.NUMERICIDENTIFIERLOOSE]}|${l[c.NONNUMERICIDENTIFIER]})`),u("PRERELEASE",`(?:-(${l[c.PRERELEASEIDENTIFIER]}(?:\\.${l[c.PRERELEASEIDENTIFIER]})*))`),u("PRERELEASELOOSE",`(?:-?(${l[c.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${l[c.PRERELEASEIDENTIFIERLOOSE]})*))`),u("BUILDIDENTIFIER",`${f}+`),u("BUILD",`(?:\\+(${l[c.BUILDIDENTIFIER]}(?:\\.${l[c.BUILDIDENTIFIER]})*))`),u("FULLPLAIN",`v?${l[c.MAINVERSION]}${l[c.PRERELEASE]}?${l[c.BUILD]}?`),u("FULL",`^${l[c.FULLPLAIN]}$`),u("LOOSEPLAIN",`[v=\\s]*${l[c.MAINVERSIONLOOSE]}${l[c.PRERELEASELOOSE]}?${l[c.BUILD]}?`),u("LOOSE",`^${l[c.LOOSEPLAIN]}$`),u("GTLT","((?:<|>)?=?)"),u("XRANGEIDENTIFIERLOOSE",`${l[c.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`),u("XRANGEIDENTIFIER",`${l[c.NUMERICIDENTIFIER]}|x|X|\\*`),u("XRANGEPLAIN",`[v=\\s]*(${l[c.XRANGEIDENTIFIER]})(?:\\.(${l[c.XRANGEIDENTIFIER]})(?:\\.(${l[c.XRANGEIDENTIFIER]})(?:${l[c.PRERELEASE]})?${l[c.BUILD]}?)?)?`),u("XRANGEPLAINLOOSE",`[v=\\s]*(${l[c.XRANGEIDENTIFIERLOOSE]})(?:\\.(${l[c.XRANGEIDENTIFIERLOOSE]})(?:\\.(${l[c.XRANGEIDENTIFIERLOOSE]})(?:${l[c.PRERELEASELOOSE]})?${l[c.BUILD]}?)?)?`),u("XRANGE",`^${l[c.GTLT]}\\s*${l[c.XRANGEPLAIN]}$`),u("XRANGELOOSE",`^${l[c.GTLT]}\\s*${l[c.XRANGEPLAINLOOSE]}$`),u("COERCE",`(^|[^\\d])(\\d{1,${n}})(?:\\.(\\d{1,${n}}))?(?:\\.(\\d{1,${n}}))?(?:$|[^\\d])`),u("COERCERTL",l[c.COERCE],!0),u("LONETILDE","(?:~>?)"),u("TILDETRIM",`(\\s*)${l[c.LONETILDE]}\\s+`,!0),t.tildeTrimReplace="$1~",u("TILDE",`^${l[c.LONETILDE]}${l[c.XRANGEPLAIN]}$`),u("TILDELOOSE",`^${l[c.LONETILDE]}${l[c.XRANGEPLAINLOOSE]}$`),u("LONECARET","(?:\\^)"),u("CARETTRIM",`(\\s*)${l[c.LONECARET]}\\s+`,!0),t.caretTrimReplace="$1^",u("CARET",`^${l[c.LONECARET]}${l[c.XRANGEPLAIN]}$`),u("CARETLOOSE",`^${l[c.LONECARET]}${l[c.XRANGEPLAINLOOSE]}$`),u("COMPARATORLOOSE",`^${l[c.GTLT]}\\s*(${l[c.LOOSEPLAIN]})$|^$`),u("COMPARATOR",`^${l[c.GTLT]}\\s*(${l[c.FULLPLAIN]})$|^$`),u("COMPARATORTRIM",`(\\s*)${l[c.GTLT]}\\s*(${l[c.LOOSEPLAIN]}|${l[c.XRANGEPLAIN]})`,!0),t.comparatorTrimReplace="$1$2$3",u("HYPHENRANGE",`^\\s*(${l[c.XRANGEPLAIN]})\\s+-\\s+(${l[c.XRANGEPLAIN]})\\s*$`),u("HYPHENRANGELOOSE",`^\\s*(${l[c.XRANGEPLAINLOOSE]})\\s+-\\s+(${l[c.XRANGEPLAINLOOSE]})\\s*$`),u("STAR","(<|>)?=?\\s*\\*"),u("GTE0","^\\s*>=\\s*0\\.0\\.0\\s*$"),u("GTE0PRE","^\\s*>=\\s*0\\.0\\.0-0\\s*$")}(de,de.exports);var ve=de.exports;const Ee=/^[0-9]+$/,be=(e,t)=>{const n=Ee.test(e),r=Ee.test(t);return n&&r&&(e=+e,t=+t),e===t?0:n&&!r?-1:r&&!n?1:ebe(t,e)};const we=me,{MAX_LENGTH:Oe,MAX_SAFE_INTEGER:Ae}=ge,{safeRe:xe,t:Ie}=ve,Ne=ue,{compareIdentifiers:Se}=ye;var $e=class e{constructor(t,n){if(n=Ne(n),t instanceof e){if(t.loose===!!n.loose&&t.includePrerelease===!!n.includePrerelease)return t;t=t.version}else if("string"!=typeof t)throw new TypeError(`Invalid version. Must be a string. Got type "${typeof t}".`);if(t.length>Oe)throw new TypeError(`version is longer than ${Oe} characters`);we("SemVer",t,n),this.options=n,this.loose=!!n.loose,this.includePrerelease=!!n.includePrerelease;const r=t.trim().match(n.loose?xe[Ie.LOOSE]:xe[Ie.FULL]);if(!r)throw new TypeError(`Invalid Version: ${t}`);if(this.raw=t,this.major=+r[1],this.minor=+r[2],this.patch=+r[3],this.major>Ae||this.major<0)throw new TypeError("Invalid major version");if(this.minor>Ae||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>Ae||this.patch<0)throw new TypeError("Invalid patch version");r[4]?this.prerelease=r[4].split(".").map((e=>{if(/^[0-9]+$/.test(e)){const t=+e;if(t>=0&&t=0;)"number"==typeof this.prerelease[r]&&(this.prerelease[r]++,r=-2);if(-1===r){if(t===this.prerelease.join(".")&&!1===n)throw new Error("invalid increment argument: identifier already exists");this.prerelease.push(e)}}if(t){let r=[t,e];!1===n&&(r=[t]),0===Se(this.prerelease[0],t)?isNaN(this.prerelease[1])&&(this.prerelease=r):this.prerelease=r}break}default:throw new Error(`invalid increment argument: ${e}`)}return this.raw=this.format(),this.build.length&&(this.raw+=`+${this.build.join(".")}`),this}};const Le=$e;var Re=(e,t,n)=>new Le(e,n).compare(new Le(t,n));const Te=Re;const De=Re;const Ce=Re;const Fe=Re;const ke=Re;const Pe=Re;const _e=(e,t,n)=>0===Te(e,t,n),Me=(e,t,n)=>0!==De(e,t,n),je=(e,t,n)=>Ce(e,t,n)>0,ze=(e,t,n)=>Fe(e,t,n)>=0,Be=(e,t,n)=>ke(e,t,n)<0,Ue=(e,t,n)=>Pe(e,t,n)<=0;var Ge,We,Xe,Ve,He=(e,t,n,r)=>{switch(t){case"===":return"object"==typeof e&&(e=e.version),"object"==typeof n&&(n=n.version),e===n;case"!==":return"object"==typeof e&&(e=e.version),"object"==typeof n&&(n=n.version),e!==n;case"":case"=":case"==":return _e(e,n,r);case"!=":return Me(e,n,r);case">":return je(e,n,r);case">=":return ze(e,n,r);case"<":return Be(e,n,r);case"<=":return Ue(e,n,r);default:throw new TypeError(`Invalid operator: ${t}`)}};function qe(){if(Ve)return Xe;Ve=1;class e{constructor(t,i){if(i=n(i),t instanceof e)return t.loose===!!i.loose&&t.includePrerelease===!!i.includePrerelease?t:new e(t.raw,i);if(t instanceof r)return this.raw=t.value,this.set=[[t]],this.format(),this;if(this.options=i,this.loose=!!i.loose,this.includePrerelease=!!i.includePrerelease,this.raw=t.trim().split(/\s+/).join(" "),this.set=this.raw.split("||").map((e=>this.parseRange(e.trim()))).filter((e=>e.length)),!this.set.length)throw new TypeError(`Invalid SemVer Range: ${this.raw}`);if(this.set.length>1){const e=this.set[0];if(this.set=this.set.filter((e=>!u(e[0]))),0===this.set.length)this.set=[e];else if(this.set.length>1)for(const e of this.set)if(1===e.length&&d(e[0])){this.set=[e];break}}this.format()}format(){return this.range=this.set.map((e=>e.join(" ").trim())).join("||").trim(),this.range}toString(){return this.range}parseRange(e){const n=((this.options.includePrerelease&&f)|(this.options.loose&&p))+":"+e,o=t.get(n);if(o)return o;const d=this.options.loose,g=d?a[s.HYPHENRANGELOOSE]:a[s.HYPHENRANGE];e=e.replace(g,N(this.options.includePrerelease)),i("hyphen replace",e),e=e.replace(a[s.COMPARATORTRIM],l),i("comparator trim",e),e=e.replace(a[s.TILDETRIM],c),i("tilde trim",e),e=e.replace(a[s.CARETTRIM],h),i("caret trim",e);let v=e.split(" ").map((e=>m(e,this.options))).join(" ").split(/\s+/).map((e=>I(e,this.options)));d&&(v=v.filter((e=>(i("loose invalid filter",e,this.options),!!e.match(a[s.COMPARATORLOOSE]))))),i("range list",v);const E=new Map,b=v.map((e=>new r(e,this.options)));for(const e of b){if(u(e))return[e];E.set(e.value,e)}E.size>1&&E.has("")&&E.delete("");const y=[...E.values()];return t.set(n,y),y}intersects(t,n){if(!(t instanceof e))throw new TypeError("a Range is required");return this.set.some((e=>g(e,n)&&t.set.some((t=>g(t,n)&&e.every((e=>t.every((t=>e.intersects(t,n)))))))))}test(e){if(!e)return!1;if("string"==typeof e)try{e=new o(e,this.options)}catch(e){return!1}for(let t=0;t")||!e.operator.startsWith(">"))&&(!this.operator.startsWith("<")||!e.operator.startsWith("<"))&&(this.semver.version!==e.semver.version||!this.operator.includes("=")||!e.operator.includes("="))&&!(o(this.semver,"<",e.semver,r)&&this.operator.startsWith(">")&&e.operator.startsWith("<"))&&!(o(this.semver,">",e.semver,r)&&this.operator.startsWith("<")&&e.operator.startsWith(">")))}}Ge=t;const n=ue,{safeRe:r,t:i}=ve,o=He,a=me,s=$e,l=qe();return Ge}(),i=me,o=$e,{safeRe:a,t:s,comparatorTrimReplace:l,tildeTrimReplace:c,caretTrimReplace:h}=ve,{FLAG_INCLUDE_PRERELEASE:f,FLAG_LOOSE:p}=ge,u=e=>"<0.0.0-0"===e.value,d=e=>""===e.value,g=(e,t)=>{let n=!0;const r=e.slice();let i=r.pop();for(;n&&r.length;)n=r.every((e=>i.intersects(e,t))),i=r.pop();return n},m=(e,t)=>(i("comp",e,t),e=y(e,t),i("caret",e),e=E(e,t),i("tildes",e),e=O(e,t),i("xrange",e),e=x(e,t),i("stars",e),e),v=e=>!e||"x"===e.toLowerCase()||"*"===e,E=(e,t)=>e.trim().split(/\s+/).map((e=>b(e,t))).join(" "),b=(e,t)=>{const n=t.loose?a[s.TILDELOOSE]:a[s.TILDE];return e.replace(n,((t,n,r,o,a)=>{let s;return i("tilde",e,t,n,r,o,a),v(n)?s="":v(r)?s=`>=${n}.0.0 <${+n+1}.0.0-0`:v(o)?s=`>=${n}.${r}.0 <${n}.${+r+1}.0-0`:a?(i("replaceTilde pr",a),s=`>=${n}.${r}.${o}-${a} <${n}.${+r+1}.0-0`):s=`>=${n}.${r}.${o} <${n}.${+r+1}.0-0`,i("tilde return",s),s}))},y=(e,t)=>e.trim().split(/\s+/).map((e=>w(e,t))).join(" "),w=(e,t)=>{i("caret",e,t);const n=t.loose?a[s.CARETLOOSE]:a[s.CARET],r=t.includePrerelease?"-0":"";return e.replace(n,((t,n,o,a,s)=>{let l;return i("caret",e,t,n,o,a,s),v(n)?l="":v(o)?l=`>=${n}.0.0${r} <${+n+1}.0.0-0`:v(a)?l="0"===n?`>=${n}.${o}.0${r} <${n}.${+o+1}.0-0`:`>=${n}.${o}.0${r} <${+n+1}.0.0-0`:s?(i("replaceCaret pr",s),l="0"===n?"0"===o?`>=${n}.${o}.${a}-${s} <${n}.${o}.${+a+1}-0`:`>=${n}.${o}.${a}-${s} <${n}.${+o+1}.0-0`:`>=${n}.${o}.${a}-${s} <${+n+1}.0.0-0`):(i("no pr"),l="0"===n?"0"===o?`>=${n}.${o}.${a}${r} <${n}.${o}.${+a+1}-0`:`>=${n}.${o}.${a}${r} <${n}.${+o+1}.0-0`:`>=${n}.${o}.${a} <${+n+1}.0.0-0`),i("caret return",l),l}))},O=(e,t)=>(i("replaceXRanges",e,t),e.split(/\s+/).map((e=>A(e,t))).join(" ")),A=(e,t)=>{e=e.trim();const n=t.loose?a[s.XRANGELOOSE]:a[s.XRANGE];return e.replace(n,((n,r,o,a,s,l)=>{i("xRange",e,n,r,o,a,s,l);const c=v(o),h=c||v(a),f=h||v(s),p=f;return"="===r&&p&&(r=""),l=t.includePrerelease?"-0":"",c?n=">"===r||"<"===r?"<0.0.0-0":"*":r&&p?(h&&(a=0),s=0,">"===r?(r=">=",h?(o=+o+1,a=0,s=0):(a=+a+1,s=0)):"<="===r&&(r="<",h?o=+o+1:a=+a+1),"<"===r&&(l="-0"),n=`${r+o}.${a}.${s}${l}`):h?n=`>=${o}.0.0${l} <${+o+1}.0.0-0`:f&&(n=`>=${o}.${a}.0${l} <${o}.${+a+1}.0-0`),i("xRange return",n),n}))},x=(e,t)=>(i("replaceStars",e,t),e.trim().replace(a[s.STAR],"")),I=(e,t)=>(i("replaceGTE0",e,t),e.trim().replace(a[t.includePrerelease?s.GTE0PRE:s.GTE0],"")),N=e=>(t,n,r,i,o,a,s,l,c,h,f,p,u)=>`${n=v(r)?"":v(i)?`>=${r}.0.0${e?"-0":""}`:v(o)?`>=${r}.${i}.0${e?"-0":""}`:a?`>=${n}`:`>=${n}${e?"-0":""}`} ${l=v(c)?"":v(h)?`<${+c+1}.0.0-0`:v(f)?`<${c}.${+h+1}.0-0`:p?`<=${c}.${h}.${f}-${p}`:e?`<${c}.${h}.${+f+1}-0`:`<=${l}`}`.trim(),S=(e,t,n)=>{for(let n=0;n0){const r=e[n].semver;if(r.major===t.major&&r.minor===t.minor&&r.patch===t.patch)return!0}return!1}return!0};return Xe}const Ye=qe();var Je=(e,t,n)=>{try{t=new Ye(t,n)}catch(e){return!1}return t.test(e)},Qe=F(Je);var Ze={NaN:NaN,E:Math.E,LN2:Math.LN2,LN10:Math.LN10,LOG2E:Math.LOG2E,LOG10E:Math.LOG10E,PI:Math.PI,SQRT1_2:Math.SQRT1_2,SQRT2:Math.SQRT2,MIN_VALUE:Number.MIN_VALUE,MAX_VALUE:Number.MAX_VALUE},Ke={"*":(e,t)=>e*t,"+":(e,t)=>e+t,"-":(e,t)=>e-t,"/":(e,t)=>e/t,"%":(e,t)=>e%t,">":(e,t)=>e>t,"<":(e,t)=>ee<=t,">=":(e,t)=>e>=t,"==":(e,t)=>e==t,"!=":(e,t)=>e!=t,"===":(e,t)=>e===t,"!==":(e,t)=>e!==t,"&":(e,t)=>e&t,"|":(e,t)=>e|t,"^":(e,t)=>e^t,"<<":(e,t)=>e<>":(e,t)=>e>>t,">>>":(e,t)=>e>>>t},et={"+":e=>+e,"-":e=>-e,"~":e=>~e,"!":e=>!e};const tt=Array.prototype.slice,nt=(e,t,n)=>{const r=n?n(t[0]):t[0];return r[e].apply(r,tt.call(t,1))};var rt={isNaN:Number.isNaN,isFinite:Number.isFinite,abs:Math.abs,acos:Math.acos,asin:Math.asin,atan:Math.atan,atan2:Math.atan2,ceil:Math.ceil,cos:Math.cos,exp:Math.exp,floor:Math.floor,log:Math.log,max:Math.max,min:Math.min,pow:Math.pow,random:Math.random,round:Math.round,sin:Math.sin,sqrt:Math.sqrt,tan:Math.tan,clamp:(e,t,n)=>Math.max(t,Math.min(n,e)),now:Date.now,utc:Date.UTC,datetime:(e,t,n,r,i,o,a)=>new Date(e,t||0,null!=n?n:1,r||0,i||0,o||0,a||0),date:e=>new Date(e).getDate(),day:e=>new Date(e).getDay(),year:e=>new Date(e).getFullYear(),month:e=>new Date(e).getMonth(),hours:e=>new Date(e).getHours(),minutes:e=>new Date(e).getMinutes(),seconds:e=>new Date(e).getSeconds(),milliseconds:e=>new Date(e).getMilliseconds(),time:e=>new Date(e).getTime(),timezoneoffset:e=>new Date(e).getTimezoneOffset(),utcdate:e=>new Date(e).getUTCDate(),utcday:e=>new Date(e).getUTCDay(),utcyear:e=>new Date(e).getUTCFullYear(),utcmonth:e=>new Date(e).getUTCMonth(),utchours:e=>new Date(e).getUTCHours(),utcminutes:e=>new Date(e).getUTCMinutes(),utcseconds:e=>new Date(e).getUTCSeconds(),utcmilliseconds:e=>new Date(e).getUTCMilliseconds(),length:e=>e.length,join:function(){return nt("join",arguments)},indexof:function(){return nt("indexOf",arguments)},lastindexof:function(){return nt("lastIndexOf",arguments)},slice:function(){return nt("slice",arguments)},reverse:e=>e.slice().reverse(),parseFloat:parseFloat,parseInt:parseInt,upper:e=>String(e).toUpperCase(),lower:e=>String(e).toLowerCase(),substring:function(){return nt("substring",arguments,String)},split:function(){return nt("split",arguments,String)},replace:function(){return nt("replace",arguments,String)},trim:e=>String(e).trim(),regexp:RegExp,test:(e,t)=>RegExp(e).test(t)};const it=["view","item","group","xy","x","y"],ot=new Set([Function,eval,setTimeout,setInterval]);"function"==typeof setImmediate&&ot.add(setImmediate);const at={Literal:(e,t)=>t.value,Identifier:(e,t)=>{const n=t.name;return e.memberDepth>0?n:"datum"===n?e.datum:"event"===n?e.event:"item"===n?e.item:Ze[n]||e.params["$"+n]},MemberExpression:(e,t)=>{const n=!t.computed,r=e(t.object);n&&(e.memberDepth+=1);const i=e(t.property);if(n&&(e.memberDepth-=1),!ot.has(r[i]))return r[i];console.error(`Prevented interpretation of member "${i}" which could lead to insecure code execution`)},CallExpression:(e,t)=>{const n=t.arguments;let r=t.callee.name;return r.startsWith("_")&&(r=r.slice(1)),"if"===r?e(n[0])?e(n[1]):e(n[2]):(e.fn[r]||rt[r]).apply(e.fn,n.map(e))},ArrayExpression:(e,t)=>t.elements.map(e),BinaryExpression:(e,t)=>Ke[t.operator](e(t.left),e(t.right)),UnaryExpression:(e,t)=>et[t.operator](e(t.argument)),ConditionalExpression:(e,t)=>e(t.test)?e(t.consequent):e(t.alternate),LogicalExpression:(e,t)=>"&&"===t.operator?e(t.left)&&e(t.right):e(t.left)||e(t.right),ObjectExpression:(e,t)=>t.properties.reduce(((t,n)=>{e.memberDepth+=1;const r=e(n.key);return e.memberDepth-=1,ot.has(e(n.value))?console.error(`Prevented interpretation of property "${r}" which could lead to insecure code execution`):t[r]=e(n.value),t}),{})};function st(e,t,n,r,i,o){const a=e=>at[e.type](a,e);return a.memberDepth=0,a.fn=Object.create(t),a.params=n,a.datum=r,a.event=i,a.item=o,it.forEach((e=>a.fn[e]=function(){return i.vega[e](...arguments)})),a(e)}var lt={operator(e,t){const n=t.ast,r=e.functions;return e=>st(n,r,e)},parameter(e,t){const n=t.ast,r=e.functions;return(e,t)=>st(n,r,t,e)},event(e,t){const n=t.ast,r=e.functions;return e=>st(n,r,void 0,void 0,e)},handler(e,t){const n=t.ast,r=e.functions;return(e,t)=>{const i=t.item&&t.item.datum;return st(n,r,e,i,t)}},encode(e,t){const{marktype:n,channels:r}=t,i=e.functions,o="group"===n||"image"===n||"rect"===n;return(e,t)=>{const a=e.datum;let s,l=0;for(const n in r)s=st(r[n].ast,i,t,a,void 0,e),e[n]!==s&&(e[n]=s,l=1);return"rule"!==n&&function(e,t,n){let r;t.x2&&(t.x?(n&&e.x>e.x2&&(r=e.x,e.x=e.x2,e.x2=r),e.width=e.x2-e.x):e.x=e.x2-(e.width||0)),t.xc&&(e.x=e.xc-(e.width||0)/2),t.y2&&(t.y?(n&&e.y>e.y2&&(r=e.y,e.y=e.y2,e.y2=r),e.height=e.y2-e.y):e.y=e.y2-(e.height||0)),t.yc&&(e.y=e.yc-(e.height||0)/2)}(e,r,o),l}}};function ct(e){const[t,n]=/schema\/([\w-]+)\/([\w\.\-]+)\.json$/g.exec(e).slice(1,3);return{library:t,version:n}}var ht="2.14.0";const ft="#fff",pt="#888",ut={background:"#333",view:{stroke:pt},title:{color:ft,subtitleColor:ft},style:{"guide-label":{fill:ft},"guide-title":{fill:ft}},axis:{domainColor:ft,gridColor:pt,tickColor:ft}},dt="#4572a7",gt={background:"#fff",arc:{fill:dt},area:{fill:dt},line:{stroke:dt,strokeWidth:2},path:{stroke:dt},rect:{fill:dt},shape:{stroke:dt},symbol:{fill:dt,strokeWidth:1.5,size:50},axis:{bandPosition:.5,grid:!0,gridColor:"#000000",gridOpacity:1,gridWidth:.5,labelPadding:10,tickSize:5,tickWidth:.5},axisBand:{grid:!1,tickExtra:!0},legend:{labelBaseline:"middle",labelFontSize:11,symbolSize:50,symbolType:"square"},range:{category:["#4572a7","#aa4643","#8aa453","#71598e","#4598ae","#d98445","#94aace","#d09393","#b9cc98","#a99cbc"]}},mt="#30a2da",vt="#cbcbcb",Et="#f0f0f0",bt="#333",yt={arc:{fill:mt},area:{fill:mt},axis:{domainColor:vt,grid:!0,gridColor:vt,gridWidth:1,labelColor:"#999",labelFontSize:10,titleColor:"#333",tickColor:vt,tickSize:10,titleFontSize:14,titlePadding:10,labelPadding:4},axisBand:{grid:!1},background:Et,group:{fill:Et},legend:{labelColor:bt,labelFontSize:11,padding:1,symbolSize:30,symbolType:"square",titleColor:bt,titleFontSize:14,titlePadding:10},line:{stroke:mt,strokeWidth:2},path:{stroke:mt,strokeWidth:.5},rect:{fill:mt},range:{category:["#30a2da","#fc4f30","#e5ae38","#6d904f","#8b8b8b","#b96db8","#ff9e27","#56cc60","#52d2ca","#52689e","#545454","#9fe4f8"],diverging:["#cc0020","#e77866","#f6e7e1","#d6e8ed","#91bfd9","#1d78b5"],heatmap:["#d6e8ed","#cee0e5","#91bfd9","#549cc6","#1d78b5"]},point:{filled:!0,shape:"circle"},shape:{stroke:mt},bar:{binSpacing:2,fill:mt,stroke:null},title:{anchor:"start",fontSize:24,fontWeight:600,offset:20}},wt="#000",Ot={group:{fill:"#e5e5e5"},arc:{fill:wt},area:{fill:wt},line:{stroke:wt},path:{stroke:wt},rect:{fill:wt},shape:{stroke:wt},symbol:{fill:wt,size:40},axis:{domain:!1,grid:!0,gridColor:"#FFFFFF",gridOpacity:1,labelColor:"#7F7F7F",labelPadding:4,tickColor:"#7F7F7F",tickSize:5.67,titleFontSize:16,titleFontWeight:"normal"},legend:{labelBaseline:"middle",labelFontSize:11,symbolSize:40},range:{category:["#000000","#7F7F7F","#1A1A1A","#999999","#333333","#B0B0B0","#4D4D4D","#C9C9C9","#666666","#DCDCDC"]}},At="Benton Gothic, sans-serif",xt="#82c6df",It="Benton Gothic Bold, sans-serif",Nt="normal",St={"category-6":["#ec8431","#829eb1","#c89d29","#3580b1","#adc839","#ab7fb4"],"fire-7":["#fbf2c7","#f9e39c","#f8d36e","#f4bb6a","#e68a4f","#d15a40","#ab4232"],"fireandice-6":["#e68a4f","#f4bb6a","#f9e39c","#dadfe2","#a6b7c6","#849eae"],"ice-7":["#edefee","#dadfe2","#c4ccd2","#a6b7c6","#849eae","#607785","#47525d"]},$t={background:"#ffffff",title:{anchor:"start",color:"#000000",font:It,fontSize:22,fontWeight:"normal"},arc:{fill:xt},area:{fill:xt},line:{stroke:xt,strokeWidth:2},path:{stroke:xt},rect:{fill:xt},shape:{stroke:xt},symbol:{fill:xt,size:30},axis:{labelFont:At,labelFontSize:11.5,labelFontWeight:"normal",titleFont:It,titleFontSize:13,titleFontWeight:Nt},axisX:{labelAngle:0,labelPadding:4,tickSize:3},axisY:{labelBaseline:"middle",maxExtent:45,minExtent:45,tickSize:2,titleAlign:"left",titleAngle:0,titleX:-45,titleY:-11},legend:{labelFont:At,labelFontSize:11.5,symbolType:"square",titleFont:It,titleFontSize:13,titleFontWeight:Nt},range:{category:St["category-6"],diverging:St["fireandice-6"],heatmap:St["fire-7"],ordinal:St["fire-7"],ramp:St["fire-7"]}},Lt="#ab5787",Rt="#979797",Tt={background:"#f9f9f9",arc:{fill:Lt},area:{fill:Lt},line:{stroke:Lt},path:{stroke:Lt},rect:{fill:Lt},shape:{stroke:Lt},symbol:{fill:Lt,size:30},axis:{domainColor:Rt,domainWidth:.5,gridWidth:.2,labelColor:Rt,tickColor:Rt,tickWidth:.2,titleColor:Rt},axisBand:{grid:!1},axisX:{grid:!0,tickSize:10},axisY:{domain:!1,grid:!0,tickSize:0},legend:{labelFontSize:11,padding:1,symbolSize:30,symbolType:"square"},range:{category:["#ab5787","#51b2e5","#703c5c","#168dd9","#d190b6","#00609f","#d365ba","#154866","#666666","#c4c4c4"]}},Dt="#3e5c69",Ct={background:"#fff",arc:{fill:Dt},area:{fill:Dt},line:{stroke:Dt},path:{stroke:Dt},rect:{fill:Dt},shape:{stroke:Dt},symbol:{fill:Dt},axis:{domainWidth:.5,grid:!0,labelPadding:2,tickSize:5,tickWidth:.5,titleFontWeight:"normal"},axisBand:{grid:!1},axisX:{gridWidth:.2},axisY:{gridDash:[3],gridWidth:.4},legend:{labelFontSize:11,padding:1,symbolType:"square"},range:{category:["#3e5c69","#6793a6","#182429","#0570b0","#3690c0","#74a9cf","#a6bddb","#e2ddf2"]}},Ft="#1696d2",kt="#000000",Pt="Lato",_t="Lato",Mt={"main-colors":["#1696d2","#d2d2d2","#000000","#fdbf11","#ec008b","#55b748","#5c5859","#db2b27"],"shades-blue":["#CFE8F3","#A2D4EC","#73BFE2","#46ABDB","#1696D2","#12719E","#0A4C6A","#062635"],"shades-gray":["#F5F5F5","#ECECEC","#E3E3E3","#DCDBDB","#D2D2D2","#9D9D9D","#696969","#353535"],"shades-yellow":["#FFF2CF","#FCE39E","#FDD870","#FCCB41","#FDBF11","#E88E2D","#CA5800","#843215"],"shades-magenta":["#F5CBDF","#EB99C2","#E46AA7","#E54096","#EC008B","#AF1F6B","#761548","#351123"],"shades-green":["#DCEDD9","#BCDEB4","#98CF90","#78C26D","#55B748","#408941","#2C5C2D","#1A2E19"],"shades-black":["#D5D5D4","#ADABAC","#848081","#5C5859","#332D2F","#262223","#1A1717","#0E0C0D"],"shades-red":["#F8D5D4","#F1AAA9","#E9807D","#E25552","#DB2B27","#A4201D","#6E1614","#370B0A"],"one-group":["#1696d2","#000000"],"two-groups-cat-1":["#1696d2","#000000"],"two-groups-cat-2":["#1696d2","#fdbf11"],"two-groups-cat-3":["#1696d2","#db2b27"],"two-groups-seq":["#a2d4ec","#1696d2"],"three-groups-cat":["#1696d2","#fdbf11","#000000"],"three-groups-seq":["#a2d4ec","#1696d2","#0a4c6a"],"four-groups-cat-1":["#000000","#d2d2d2","#fdbf11","#1696d2"],"four-groups-cat-2":["#1696d2","#ec0008b","#fdbf11","#5c5859"],"four-groups-seq":["#cfe8f3","#73bf42","#1696d2","#0a4c6a"],"five-groups-cat-1":["#1696d2","#fdbf11","#d2d2d2","#ec008b","#000000"],"five-groups-cat-2":["#1696d2","#0a4c6a","#d2d2d2","#fdbf11","#332d2f"],"five-groups-seq":["#cfe8f3","#73bf42","#1696d2","#0a4c6a","#000000"],"six-groups-cat-1":["#1696d2","#ec008b","#fdbf11","#000000","#d2d2d2","#55b748"],"six-groups-cat-2":["#1696d2","#d2d2d2","#ec008b","#fdbf11","#332d2f","#0a4c6a"],"six-groups-seq":["#cfe8f3","#a2d4ec","#73bfe2","#46abdb","#1696d2","#12719e"],"diverging-colors":["#ca5800","#fdbf11","#fdd870","#fff2cf","#cfe8f3","#73bfe2","#1696d2","#0a4c6a"]},jt={background:"#FFFFFF",title:{anchor:"start",fontSize:18,font:Pt},axisX:{domain:!0,domainColor:kt,domainWidth:1,grid:!1,labelFontSize:12,labelFont:_t,labelAngle:0,tickColor:kt,tickSize:5,titleFontSize:12,titlePadding:10,titleFont:Pt},axisY:{domain:!1,domainWidth:1,grid:!0,gridColor:"#DEDDDD",gridWidth:1,labelFontSize:12,labelFont:_t,labelPadding:8,ticks:!1,titleFontSize:12,titlePadding:10,titleFont:Pt,titleAngle:0,titleY:-10,titleX:18},legend:{labelFontSize:12,labelFont:_t,symbolSize:100,titleFontSize:12,titlePadding:10,titleFont:Pt,orient:"right",offset:10},view:{stroke:"transparent"},range:{category:Mt["six-groups-cat-1"],diverging:Mt["diverging-colors"],heatmap:Mt["diverging-colors"],ordinal:Mt["six-groups-seq"],ramp:Mt["shades-blue"]},area:{fill:Ft},rect:{fill:Ft},line:{color:Ft,stroke:Ft,strokeWidth:5},trail:{color:Ft,stroke:Ft,strokeWidth:0,size:1},path:{stroke:Ft,strokeWidth:.5},point:{filled:!0},text:{font:"Lato",color:Ft,fontSize:11,align:"center",fontWeight:400,size:11},style:{bar:{fill:Ft,stroke:null}},arc:{fill:Ft},shape:{stroke:Ft},symbol:{fill:Ft,size:30}},zt="#3366CC",Bt="#ccc",Ut="Arial, sans-serif",Gt={arc:{fill:zt},area:{fill:zt},path:{stroke:zt},rect:{fill:zt},shape:{stroke:zt},symbol:{stroke:zt},circle:{fill:zt},background:"#fff",padding:{top:10,right:10,bottom:10,left:10},style:{"guide-label":{font:Ut,fontSize:12},"guide-title":{font:Ut,fontSize:12},"group-title":{font:Ut,fontSize:12}},title:{font:Ut,fontSize:14,fontWeight:"bold",dy:-3,anchor:"start"},axis:{gridColor:Bt,tickColor:Bt,domain:!1,grid:!0},range:{category:["#4285F4","#DB4437","#F4B400","#0F9D58","#AB47BC","#00ACC1","#FF7043","#9E9D24","#5C6BC0","#F06292","#00796B","#C2185B"],heatmap:["#c6dafc","#5e97f6","#2a56c6"]}},Wt=e=>e*(1/3+1),Xt=Wt(9),Vt=Wt(10),Ht=Wt(12),qt="Segoe UI",Yt="wf_standard-font, helvetica, arial, sans-serif",Jt="#252423",Qt="#605E5C",Zt="transparent",Kt="#118DFF",en="#DEEFFF",tn=[en,Kt],nn={view:{stroke:Zt},background:Zt,font:qt,header:{titleFont:Yt,titleFontSize:Ht,titleColor:Jt,labelFont:qt,labelFontSize:Vt,labelColor:Qt},axis:{ticks:!1,grid:!1,domain:!1,labelColor:Qt,labelFontSize:Xt,titleFont:Yt,titleColor:Jt,titleFontSize:Ht,titleFontWeight:"normal"},axisQuantitative:{tickCount:3,grid:!0,gridColor:"#C8C6C4",gridDash:[1,5],labelFlush:!1},axisBand:{tickExtra:!0},axisX:{labelPadding:5},axisY:{labelPadding:10},bar:{fill:Kt},line:{stroke:Kt,strokeWidth:3,strokeCap:"round",strokeJoin:"round"},text:{font:qt,fontSize:Xt,fill:Qt},arc:{fill:Kt},area:{fill:Kt,line:!0,opacity:.6},path:{stroke:Kt},rect:{fill:Kt},point:{fill:Kt,filled:!0,size:75},shape:{stroke:Kt},symbol:{fill:Kt,strokeWidth:1.5,size:50},legend:{titleFont:qt,titleFontWeight:"bold",titleColor:Qt,labelFont:qt,labelFontSize:Vt,labelColor:Qt,symbolType:"circle",symbolSize:75},range:{category:[Kt,"#12239E","#E66C37","#6B007B","#E044A7","#744EC2","#D9B300","#D64550"],diverging:tn,heatmap:tn,ordinal:[en,"#c7e4ff","#b0d9ff","#9aceff","#83c3ff","#6cb9ff","#55aeff","#3fa3ff","#2898ff",Kt]}},rn='IBM Plex Sans,system-ui,-apple-system,BlinkMacSystemFont,".sfnstext-regular",sans-serif',on=["#8a3ffc","#33b1ff","#007d79","#ff7eb6","#fa4d56","#fff1f1","#6fdc8c","#4589ff","#d12771","#d2a106","#08bdba","#bae6ff","#ba4e00","#d4bbff"],an=["#6929c4","#1192e8","#005d5d","#9f1853","#fa4d56","#570408","#198038","#002d9c","#ee538b","#b28600","#009d9a","#012749","#8a3800","#a56eff"];function sn({type:e,background:t}){const n="dark"===e?"#161616":"#ffffff",r="dark"===e?"#f4f4f4":"#161616",i="dark"===e?"#d4bbff":"#6929c4";return{background:t,arc:{fill:i},area:{fill:i},path:{stroke:i},rect:{fill:i},shape:{stroke:i},symbol:{stroke:i},circle:{fill:i},view:{fill:n,stroke:n},group:{fill:n},title:{color:r,anchor:"start",dy:-15,fontSize:16,font:rn,fontWeight:600},axis:{labelColor:r,labelFontSize:12,grid:!0,gridColor:"#525252",titleColor:r,labelAngle:0},style:{"guide-label":{font:rn,fill:r,fontWeight:400},"guide-title":{font:rn,fill:r,fontWeight:400}},range:{category:"dark"===e?on:an,diverging:["#750e13","#a2191f","#da1e28","#fa4d56","#ff8389","#ffb3b8","#ffd7d9","#fff1f1","#e5f6ff","#bae6ff","#82cfff","#33b1ff","#1192e8","#0072c3","#00539a","#003a6d"],heatmap:["#f6f2ff","#e8daff","#d4bbff","#be95ff","#a56eff","#8a3ffc","#6929c4","#491d8b","#31135e","#1c0f30"]}}}const ln=sn({type:"light",background:"#ffffff"}),cn=sn({type:"light",background:"#f4f4f4"}),hn=sn({type:"dark",background:"#262626"}),fn=sn({type:"dark",background:"#161616"}),pn=ht;var un=Object.freeze({__proto__:null,carbong10:cn,carbong100:fn,carbong90:hn,carbonwhite:ln,dark:ut,excel:gt,fivethirtyeight:yt,ggplot2:Ot,googlecharts:Gt,latimes:$t,powerbi:nn,quartz:Tt,urbaninstitute:jt,version:pn,vox:Ct});function dn(e,t,n){return e.fields=t||[],e.fname=n,e}function gn(e){return 1===e.length?mn(e[0]):vn(e)}const mn=e=>function(t){return t[e]},vn=e=>{const t=e.length;return function(n){for(let r=0;rr&&c(),s=r=i+1):"]"===o&&(s||En("Access path missing open bracket: "+e),s>0&&c(),s=0,r=i+1):i>r?c():r=i+1}return s&&En("Access path missing closing bracket: "+e),a&&En("Access path missing closing quote: "+e),i>r&&(i++,c()),t}(e);e=1===r.length?r[0]:e,dn((n&&n.get||gn)(r),[e],t||e)}("id"),dn((e=>e),[],"identity"),dn((()=>0),[],"zero"),dn((()=>1),[],"one"),dn((()=>!0),[],"true"),dn((()=>!1),[],"false");var bn=Array.isArray;function yn(e){return e===Object(e)}function wn(e){return wn="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},wn(e)}function On(e){var t=function(e,t){if("object"!==wn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==wn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===wn(t)?t:String(t)}function An(e,t,n){return(t=On(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function xn(e,t){if(null==e)return{};var n,r,i=function(e,t){if(null==e)return{};var n,r,i={},o=Object.keys(e);for(r=0;r=0||(i[n]=e[n]);return i}(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(i[n]=e[n])}return i}const In=["title","image"];function Nn(e,t){return JSON.stringify(e,function(e){const t=[];return function(n,r){if("object"!=typeof r||null===r)return r;const i=t.indexOf(this)+1;return t.length=i,t.length>e?"[Object]":t.indexOf(r)>=0?"[Circular]":(t.push(r),r)}}(t))}var Sn="#vg-tooltip-element {\n visibility: hidden;\n padding: 8px;\n position: fixed;\n z-index: 1000;\n font-family: sans-serif;\n font-size: 11px;\n border-radius: 3px;\n box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);\n /* The default theme is the light theme. */\n background-color: rgba(255, 255, 255, 0.95);\n border: 1px solid #d9d9d9;\n color: black;\n}\n#vg-tooltip-element.visible {\n visibility: visible;\n}\n#vg-tooltip-element h2 {\n margin-top: 0;\n margin-bottom: 10px;\n font-size: 13px;\n}\n#vg-tooltip-element table {\n border-spacing: 0;\n}\n#vg-tooltip-element table tr {\n border: none;\n}\n#vg-tooltip-element table tr td {\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n padding-bottom: 2px;\n}\n#vg-tooltip-element table tr td.key {\n color: #808080;\n max-width: 150px;\n text-align: right;\n padding-right: 4px;\n}\n#vg-tooltip-element table tr td.value {\n display: block;\n max-width: 300px;\n max-height: 7em;\n text-align: left;\n}\n#vg-tooltip-element.dark-theme {\n background-color: rgba(32, 32, 32, 0.9);\n border: 1px solid #f5f5f5;\n color: white;\n}\n#vg-tooltip-element.dark-theme td.key {\n color: #bfbfbf;\n}\n";const $n="vg-tooltip-element",Ln={offsetX:10,offsetY:10,id:$n,styleId:"vega-tooltip-style",theme:"light",disableDefaultStyle:!1,sanitize:function(e){return String(e).replace(/&/g,"&").replace(/t("string"==typeof e?e:Nn(e,n)))).join(", ")}]`;if(yn(e)){let r="";const i=e,{title:o,image:a}=i,s=xn(i,In);o&&(r+=`${t(o)}
`),a&&(r+=`
`);const l=Object.keys(s);if(l.length>0){r+="";for(const e of l){let i=s[e];void 0!==i&&(yn(i)&&(i=Nn(i,n)),r+=`${t(e)}: | ${t(i)} |
`)}r+="
"}return r||"{}"}return t(e)}};function Rn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Tn(e){for(var t=1;t0?n.insertBefore(e,n.childNodes[0]):n.appendChild(e)}}tooltipHandler(e,t,n,r){if(this.el=document.getElementById(this.options.id),!this.el){this.el=document.createElement("div"),this.el.setAttribute("id",this.options.id),this.el.classList.add("vg-tooltip");(document.fullscreenElement??document.body).appendChild(this.el)}if(null==r||""===r)return void this.el.classList.remove("visible",`${this.options.theme}-theme`);this.el.innerHTML=this.options.formatTooltip(r,this.options.sanitize,this.options.maxDepth),this.el.classList.add("visible",`${this.options.theme}-theme`);const{x:i,y:o}=function(e,t,n,r){let i=e.clientX+n;i+t.width>window.innerWidth&&(i=+e.clientX-n-t.width);let o=e.clientY+r;return o+t.height>window.innerHeight&&(o=+e.clientY-r-t.height),{x:i,y:o}}(t,this.el.getBoundingClientRect(),this.options.offsetX,this.options.offsetY);this.el.style.top=`${o}px`,this.el.style.left=`${i}px`}}var Cn='.vega-embed {\n position: relative;\n display: inline-block;\n box-sizing: border-box;\n}\n.vega-embed.has-actions {\n padding-right: 38px;\n}\n.vega-embed details:not([open]) > :not(summary) {\n display: none !important;\n}\n.vega-embed summary {\n list-style: none;\n position: absolute;\n top: 0;\n right: 0;\n padding: 6px;\n z-index: 1000;\n background: white;\n box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1);\n color: #1b1e23;\n border: 1px solid #aaa;\n border-radius: 999px;\n opacity: 0.2;\n transition: opacity 0.4s ease-in;\n cursor: pointer;\n line-height: 0px;\n}\n.vega-embed summary::-webkit-details-marker {\n display: none;\n}\n.vega-embed summary:active {\n box-shadow: #aaa 0px 0px 0px 1px inset;\n}\n.vega-embed summary svg {\n width: 14px;\n height: 14px;\n}\n.vega-embed details[open] summary {\n opacity: 0.7;\n}\n.vega-embed:hover summary, .vega-embed:focus-within summary {\n opacity: 1 !important;\n transition: opacity 0.2s ease;\n}\n.vega-embed .vega-actions {\n position: absolute;\n z-index: 1001;\n top: 35px;\n right: -9px;\n display: flex;\n flex-direction: column;\n padding-bottom: 8px;\n padding-top: 8px;\n border-radius: 4px;\n box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.2);\n border: 1px solid #d9d9d9;\n background: white;\n animation-duration: 0.15s;\n animation-name: scale-in;\n animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1.5);\n text-align: left;\n}\n.vega-embed .vega-actions a {\n padding: 8px 16px;\n font-family: sans-serif;\n font-size: 14px;\n font-weight: 600;\n white-space: nowrap;\n color: #434a56;\n text-decoration: none;\n}\n.vega-embed .vega-actions a:hover, .vega-embed .vega-actions a:focus {\n background-color: #f7f7f9;\n color: black;\n}\n.vega-embed .vega-actions::before, .vega-embed .vega-actions::after {\n content: "";\n display: inline-block;\n position: absolute;\n}\n.vega-embed .vega-actions::before {\n left: auto;\n right: 14px;\n top: -16px;\n border: 8px solid rgba(0, 0, 0, 0);\n border-bottom-color: #d9d9d9;\n}\n.vega-embed .vega-actions::after {\n left: auto;\n right: 15px;\n top: -14px;\n border: 7px solid rgba(0, 0, 0, 0);\n border-bottom-color: #fff;\n}\n.vega-embed .chart-wrapper.fit-x {\n width: 100%;\n}\n.vega-embed .chart-wrapper.fit-y {\n height: 100%;\n}\n\n.vega-embed-wrapper {\n max-width: 100%;\n overflow: auto;\n padding-right: 14px;\n}\n\n@keyframes scale-in {\n from {\n opacity: 0;\n transform: scale(0.6);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n}\n';function Fn(e,...t){for(const n of t)kn(e,n);return e}function kn(t,n){for(const r of Object.keys(n))e.writeConfig(t,r,n[r],!0)}const Pn="6.22.2",_n=i;let Mn=o;const jn="undefined"!=typeof window?window:void 0;void 0===Mn&&jn?.vl?.compile&&(Mn=jn.vl);const zn={export:{svg:!0,png:!0},source:!0,compiled:!0,editor:!0},Bn={CLICK_TO_VIEW_ACTIONS:"Click to view actions",COMPILED_ACTION:"View Compiled Vega",EDITOR_ACTION:"Open in Vega Editor",PNG_ACTION:"Save as PNG",SOURCE_ACTION:"View Source",SVG_ACTION:"Save as SVG"},Un={vega:"Vega","vega-lite":"Vega-Lite"},Gn={vega:_n.version,"vega-lite":Mn?Mn.version:"not available"},Wn={vega:e=>e,"vega-lite":(e,t)=>Mn.compile(e,{config:t}).spec},Xn='\n',Vn="chart-wrapper";function Hn(e,t,n,r){const i=`${t}`,o=`
${n}`,a=window.open("");a.document.write(i+e+o),a.document.title=`${Un[r]} JSON Source`}function qn(e){return(t=e)&&"load"in t?e:_n.loader(e);var t}async function Yn(t,n,r={}){let i,o;e.isString(n)?(o=qn(r.loader),i=JSON.parse(await o.load(n))):i=n;const a=function(t){const n=t.usermeta?.embedOptions??{};return e.isString(n.defaultStyle)&&(n.defaultStyle=!1),n}(i),s=a.loader;o&&!s||(o=qn(r.loader??s));const l=await Jn(a,o),c=await Jn(r,o),h={...Fn(c,l),config:e.mergeConfig(c.config??{},l.config??{})};return await async function(t,n,r={},i){const o=r.theme?e.mergeConfig(un[r.theme],r.config??{}):r.config,a=e.isBoolean(r.actions)?r.actions:Fn({},zn,r.actions??{}),s={...Bn,...r.i18n},l=r.renderer??"canvas",c=r.logLevel??_n.Warn,h=r.downloadFileName??"visualization",f="string"==typeof t?document.querySelector(t):t;if(!f)throw new Error(`${t} does not exist`);if(!1!==r.defaultStyle){const e="vega-embed-style",{root:t,rootContainer:n}=function(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}(f);if(!t.getElementById(e)){const t=document.createElement("style");t.id=e,t.innerHTML=void 0===r.defaultStyle||!0===r.defaultStyle?Cn.toString():r.defaultStyle,n.appendChild(t)}}const p=function(e,t){if(e.$schema){const n=ct(e.$schema);t&&t!==n.library&&console.warn(`The given visualization spec is written in ${Un[n.library]}, but mode argument sets ${Un[t]??t}.`);const r=n.library;return Qe(Gn[r],`^${n.version.slice(1)}`)||console.warn(`The input spec uses ${Un[r]} ${n.version}, but the current version of ${Un[r]} is v${Gn[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":t??"vega"}(n,r.mode);let u=Wn[p](n,o);if("vega-lite"===p&&u.$schema){const e=ct(u.$schema);Qe(Gn.vega,`^${e.version.slice(1)}`)||console.warn(`The compiled spec uses Vega ${e.version}, but current version is v${Gn.vega}.`)}f.classList.add("vega-embed"),a&&f.classList.add("has-actions");f.innerHTML="";let d=f;if(a){const e=document.createElement("div");e.classList.add(Vn),f.appendChild(e),d=e}const g=r.patch;g&&(u=g instanceof Function?g(u):A(u,g,!0,!1).newDocument);r.formatLocale&&_n.formatLocale(r.formatLocale);r.timeFormatLocale&&_n.timeFormatLocale(r.timeFormatLocale);if(r.expressionFunctions)for(const e in r.expressionFunctions){const t=r.expressionFunctions[e];"fn"in t?_n.expressionFunction(e,t.fn,t.visitor):t instanceof Function&&_n.expressionFunction(e,t)}const{ast:m}=r,v=_n.parse(u,"vega-lite"===p?{}:o,{ast:m}),E=new(r.viewClass||_n.View)(v,{loader:i,logLevel:c,renderer:l,...m?{expr:_n.expressionInterpreter??r.expr??lt}:{}});if(E.addSignalListener("autosize",((e,t)=>{const{type:n}=t;"fit-x"==n?(d.classList.add("fit-x"),d.classList.remove("fit-y")):"fit-y"==n?(d.classList.remove("fit-x"),d.classList.add("fit-y")):"fit"==n?d.classList.add("fit-x","fit-y"):d.classList.remove("fit-x","fit-y")})),!1!==r.tooltip){const e="function"==typeof r.tooltip?r.tooltip:new Dn(!0===r.tooltip?{}:r.tooltip).call;E.tooltip(e)}let b,{hover:y}=r;void 0===y&&(y="vega"===p);if(y){const{hoverSet:e,updateSet:t}="boolean"==typeof y?{}:y;E.hover(e,t)}r&&(null!=r.width&&E.width(r.width),null!=r.height&&E.height(r.height),null!=r.padding&&E.padding(r.padding));if(await E.initialize(d,r.bind).runAsync(),!1!==a){let t=f;if(!1!==r.defaultStyle){const e=document.createElement("details");e.title=s.CLICK_TO_VIEW_ACTIONS,f.append(e),t=e;const n=document.createElement("summary");n.innerHTML=Xn,e.append(n),b=t=>{e.contains(t.target)||e.removeAttribute("open")},document.addEventListener("click",b)}const i=document.createElement("div");if(t.append(i),i.classList.add("vega-actions"),!0===a||!1!==a.export)for(const t of["svg","png"])if(!0===a||!0===a.export||a.export[t]){const n=s[`${t.toUpperCase()}_ACTION`],o=document.createElement("a"),a=e.isObject(r.scaleFactor)?r.scaleFactor[t]:r.scaleFactor;o.text=n,o.href="#",o.target="_blank",o.download=`${h}.${t}`,o.addEventListener("mousedown",(async function(e){e.preventDefault();const n=await E.toImageURL(t,a);this.href=n})),i.append(o)}if(!0===a||!1!==a.source){const e=document.createElement("a");e.text=s.SOURCE_ACTION,e.href="#",e.addEventListener("click",(function(e){Hn(j(n),r.sourceHeader??"",r.sourceFooter??"",p),e.preventDefault()})),i.append(e)}if("vega-lite"===p&&(!0===a||!1!==a.compiled)){const e=document.createElement("a");e.text=s.COMPILED_ACTION,e.href="#",e.addEventListener("click",(function(e){Hn(j(u),r.sourceHeader??"",r.sourceFooter??"","vega"),e.preventDefault()})),i.append(e)}if(!0===a||!1!==a.editor){const e=r.editorUrl??"https://vega.github.io/editor/",t=document.createElement("a");t.text=s.EDITOR_ACTION,t.href="#",t.addEventListener("click",(function(t){!function(e,t,n){const r=e.open(t),{origin:i}=new URL(t);let o=40;e.addEventListener("message",(function t(n){n.source===r&&(o=0,e.removeEventListener("message",t,!1))}),!1),setTimeout((function e(){o<=0||(r.postMessage(n,i),setTimeout(e,250),o-=1)}),250)}(window,e,{config:o,mode:p,renderer:l,spec:j(n)}),t.preventDefault()})),i.append(t)}}function w(){b&&document.removeEventListener("click",b),E.finalize()}return{view:E,spec:n,vgSpec:u,finalize:w,embedOptions:r}}(t,i,h,o)}async function Jn(t,n){const r=e.isString(t.config)?JSON.parse(await n.load(t.config)):t.config??{},i=e.isString(t.patch)?JSON.parse(await n.load(t.patch)):t.patch;return{...t,...i?{patch:i}:{},...r?{config:r}:{}}}async function Qn(e,t={}){const n=document.createElement("div");n.classList.add("vega-embed-wrapper");const r=document.createElement("div");n.appendChild(r);const i=!0===t.actions||!1===t.actions?t.actions:{export:!0,source:!1,compiled:!0,editor:!0,...t.actions??{}},o=await Yn(r,e,{actions:i,...t??{}});return n.value=o.view,n}const Zn=(...t)=>{return t.length>1&&(e.isString(t[0])&&!((n=t[0]).startsWith("http://")||n.startsWith("https://")||n.startsWith("//"))||t[0]instanceof HTMLElement||3===t.length)?Yn(t[0],t[1],t[2]):Qn(t[0],t[1]);var n};return Zn.vegaLite=Mn,Zn.vl=Mn,Zn.container=Qn,Zn.embed=Yn,Zn.vega=_n,Zn.default=Yn,Zn.version=Pn,Zn}));
7 | //# sourceMappingURL=vega-embed.min.js.map
8 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # HappyAPI
2 |
3 | https://github.com/timothypratley/happyapi
4 |
--------------------------------------------------------------------------------
/notebooks/happy/notebook/ocr.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.notebook.ocr
2 | (:require [clojure.test :refer [deftest is]]
3 | [happyapi.oauth2.credentials :as credentials]
4 | [happygapi.vision :as vision]
5 | ;; TODO:
6 | [happygapi.documentai :as dai]))
7 |
8 | (deftest t
9 | (credentials/init!)
10 | (vision/images-annotate {:requests [{:image {:source {:imageUri "https://i0.wp.com/static.flickr.com/102/308775600_4ca34de425_o.jpg"}},
11 | :features [{:type "DOCUMENT_TEXT_DETECTION"}],
12 | :imageContext {:languageHints ["Tamil"]}}]}))
13 |
--------------------------------------------------------------------------------
/notebooks/happy/notebook/sheets.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.notebook.sheets
2 | (:require [clojure.test :refer [deftest is testing]]
3 | [happygapi.sheets :as sheets]))
4 |
5 | (def spreadsheet-id "1NbGRyCRMoOT_MLhnubC5900JNwiQq_uqvdKwbqZOfyM")
6 |
7 | #_
8 | (deftest get$-test
9 | (credentials/init!)
10 | (testing "When fetching a spreadsheet"
11 | (let [spreadsheet (sheets/get$ (credentials/auth!)
12 | {:spreadsheetId spreadsheet-id})]
13 | (is (map? spreadsheet) "should receive spreadsheet info")
14 | (is (seq spreadsheet) "should contain properties")))
15 |
16 | (testing "When missing a required key"
17 | (is (thrown? AssertionError (sheets/get$ (credentials/auth!) {:badKey "123"}))
18 | "should get an exception")))
19 | #_
20 | (deftest values-batchUpdate$-test
21 | (credentials/init!)
22 | (testing "When updating values in a sheet"
23 | (let [response (sheets/values-batchUpdate$ (credentials/auth!)
24 | {:spreadsheetId spreadsheet-id}
25 | {:valueInputOption "USER_ENTERED"
26 | :data [{:range "Sheet1"
27 | :values [[1 2 3]
28 | [4 5 6]]}]})]
29 | (is (map? response) "should receive a summary")
30 | (is (seq response) "containing data"))))
31 |
--------------------------------------------------------------------------------
/notebooks/happy/notebook/youtube_clojuretv.clj:
--------------------------------------------------------------------------------
1 | (ns happy.notebook.youtube-clojuretv
2 | (:require [clojure.string :as str]
3 | [scicloj.kindly.v4.kind :as kind]
4 | [happyapi.google.youtube-v3 :as youtube]))
5 |
6 | ;; # ClojureTV video views analysis
7 |
8 | ;; [Clojure/Conj 2024](https://2024.clojure-conj.org/) is coming soon.
9 | ;; As people prepare their talk proposals, it may be interesting to consider what talks have been popular in the past?
10 | ;; In this article we will gather view statistics from the YouTube API and try to answer some questions:
11 |
12 | ;; * What is the distribution of views?
13 | ;; * What are the most viewed talks?
14 | ;; * What are the most liked talks?
15 | ;; * Which talks have a high like per view ratio?
16 | ;; * Which talks have been most commented upon?
17 |
18 | ;; ## Dataset
19 |
20 | ;; We want to get all the videos posted on ClojureTV.
21 | ;; The way to do this is to look at the "uploads" playlist.
22 | ;; First we need to find the channel that ClojureTV videos are published on.
23 |
24 | (defonce channels
25 | (youtube/channels-list "contentDetails,statistics" {:forUsername "ClojureTV"}))
26 |
27 | (def uploads-playlist-id
28 | (get-in (first channels) ["contentDetails" "relatedPlaylists" "uploads"]))
29 |
30 | (defonce playlist
31 | (youtube/playlistItems-list "contentDetails,id" {:playlistId uploads-playlist-id}))
32 |
33 | ;; The playlist contains videoIds which we can use to access video view/like statistics.
34 |
35 | (def video-ids
36 | (mapv #(get-in % ["contentDetails" "videoId"]) playlist))
37 |
38 | ;; Video details can be requested in batches of at most 50 due to the maximum item count per response page.
39 |
40 | (defonce videos-raw
41 | (vec (mapcat (fn [batch]
42 | (youtube/videos-list "snippet,contentDetails,statistics" {:id (str/join "," batch)}))
43 | (partition-all 50 video-ids))))
44 |
45 | ;; Let's check how many videos we got
46 |
47 | (count videos-raw)
48 |
49 | ;; And what the data looks like
50 |
51 | (first videos-raw)
52 |
53 | ;; Statistics were interpreted as strings instead of numbers, so we'll need to fix that.
54 |
55 | (def videos
56 | (mapv (fn [video]
57 | (update video "statistics" update-vals parse-long))
58 | videos-raw))
59 |
60 | ;; ## Distribution of views
61 |
62 | ;; The first place to start getting a feel for a dataset is often plotting any relevant distributions.
63 | ;; In this case it makes sense to investigate the distribution of view counts per video.
64 |
65 | (kind/vega-lite
66 | {:title "ClojureTV views per video"
67 | :data {:values videos}
68 | :width 400
69 | :layer [{:mark {:type "point" :tooltip true}
70 | :encoding {:x {:field "statistics.viewCount" :type "ordinal" :title "video" :axis {:labels false} :sort "-y"}
71 | :y {:field "statistics.viewCount" :type "quantitative" :title "views"}
72 | :tooltip {:field "snippet.title"}}}
73 | {:mark {:type "rule" :color "red"}
74 | :encoding {:y {:field "statistics.viewCount" :type "quantitative" :aggregate "max"}}}]})
75 |
76 | ;; Just a few videos get a huge amount of views.
77 | ;; This is a fairly common Pareto style distribution.
78 | ;; We'll be able to understand it better on a log scale.
79 |
80 | (kind/vega-lite
81 | {:title "ClojureTV log scale view quartiles"
82 | :data {:values videos}
83 | :width 400
84 | :layer [{:mark {:type "point" :tooltip true}
85 | :encoding {:x {:field "statistics.viewCount" :type "ordinal" :title "video" :axis {:labels false} :sort "-y"}
86 | :y {:field "statistics.viewCount" :type "quantitative" :title "views" :scale {:type "log"}}
87 | :tooltip {:field "snippet.title"}}}
88 | {:mark {:type "rule" :color "red"}
89 | :encoding {:y {:field "statistics.viewCount" :aggregate "q1"}}}
90 | {:mark {:type "rule" :color "red"}
91 | :encoding {:y {:field "statistics.viewCount" :aggregate "median"}}}
92 | {:mark {:type "rule" :color "red"}
93 | :encoding {:y {:field "statistics.viewCount" :aggregate "q3"}}}]})
94 |
95 | ;; Now we can more clearly see that most ClojureTV videos get around 4.5k views, with 50% ranging from 2.5k views to 10k views.
96 |
97 | (def view-icon
98 | [:svg {:xmlns "http://www.w3.org/2000/svg" :width "1.13em" :height "1em" :viewBox "0 0 576 512"}
99 | [:path {:fill "currentColor" :d "M288 144a110.94 110.94 0 0 0-31.24 5a55.4 55.4 0 0 1 7.24 27a56 56 0 0 1-56 56a55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144m284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19M288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400"}]])
100 |
101 | (def like-icon
102 | [:svg {:xmlns "http://www.w3.org/2000/svg" :width "1em" :height "1em" :viewBox "0 0 512 512"}
103 | [:path {:fill "currentColor" :d "M466.27 286.69C475.04 271.84 480 256 480 236.85c0-44.015-37.218-85.58-85.82-85.58H357.7c4.92-12.81 8.85-28.13 8.85-46.54C366.55 31.936 328.86 0 271.28 0c-61.607 0-58.093 94.933-71.76 108.6c-22.747 22.747-49.615 66.447-68.76 83.4H32c-17.673 0-32 14.327-32 32v240c0 17.673 14.327 32 32 32h64c14.893 0 27.408-10.174 30.978-23.95c44.509 1.001 75.06 39.94 177.802 39.94c7.22 0 15.22.01 22.22.01c77.117 0 111.986-39.423 112.94-95.33c13.319-18.425 20.299-43.122 17.34-66.99c9.854-18.452 13.664-40.343 8.99-62.99m-61.75 53.83c12.56 21.13 1.26 49.41-13.94 57.57c7.7 48.78-17.608 65.9-53.12 65.9h-37.82c-71.639 0-118.029-37.82-171.64-37.82V240h10.92c28.36 0 67.98-70.89 94.54-97.46c28.36-28.36 18.91-75.63 37.82-94.54c47.27 0 47.27 32.98 47.27 56.73c0 39.17-28.36 56.72-28.36 94.54h103.99c21.11 0 37.73 18.91 37.82 37.82c.09 18.9-12.82 37.81-22.27 37.81c13.489 14.555 16.371 45.236-5.21 65.62M88 432c0 13.255-10.745 24-24 24s-24-10.745-24-24s10.745-24 24-24s24 10.745 24 24"}]])
104 |
105 | (def comment-icon
106 | [:svg {:xmlns "http://www.w3.org/2000/svg" :width "1em" :height "1em" :viewBox "0 0 512 512"}
107 | [:path {:fill "currentColor" :d "M256 32C114.6 32 0 125.1 0 240c0 47.6 19.9 91.2 52.9 126.3C38 405.7 7 439.1 6.5 439.5c-6.6 7-8.4 17.2-4.6 26S14.4 480 24 480c61.5 0 110-25.7 139.1-46.3C192 442.8 223.2 448 256 448c141.4 0 256-93.1 256-208S397.4 32 256 32m0 368c-26.7 0-53.1-4.1-78.4-12.1l-22.7-7.2l-19.5 13.8c-14.3 10.1-33.9 21.4-57.5 29c7.3-12.1 14.4-25.7 19.9-40.2l10.6-28.1l-20.6-21.8C69.7 314.1 48 282.2 48 240c0-88.2 93.3-160 208-160s208 71.8 208 160s-93.3 160-208 160"}]])
108 |
109 | (defn video-summary [{:strs [id]
110 | {:strs [viewCount likeCount commentCount]} "statistics"
111 | {:strs [title description] {{:strs [url]} "default"} "thumbnails"} "snippet"}]
112 | (kind/hiccup
113 | [:div {:style {:display "grid"
114 | :gap "15px"
115 | :grid-template-areas "'t t t t t t'
116 | 'i i i d d d'
117 | 's s s d d d'"}}
118 | [:div {:style {:grid-area "t"}} [:strong title]]
119 | [:div {:style {:grid-area "i"}} [:a {:href (str "https://youtube.com/watch?v=" id) :target "_blank"}
120 | [:img {:src url}]]]
121 | [:div {:style {:grid-area "d"}} description]
122 | [:div {:style {:grid-area "s"
123 | :text-align "right"}}
124 | [:div viewCount " " view-icon]
125 | [:div likeCount " " like-icon]
126 | [:div commentCount " " comment-icon]]]))
127 |
128 | (defn video-table [videos]
129 | (kind/hiccup
130 | [:table {:style {:width "100%"}}
131 | [:thead [:tr
132 | [:th {:style {:text-align "right"
133 | :padding "10px"}} "Rank"]
134 | [:th {:style {:padding "10px"}} "Title"]
135 | [:th {:style {:text-align "right"
136 | :padding "10px"}} "Views"]
137 | [:th {:style {:text-align "right"
138 | :padding "10px"}} "Likes"]
139 | [:th {:style {:text-align "right"
140 | :padding "10px"}} "Comments"]
141 | [:th {:style {:padding "10px"}} "Video"]]]
142 | (into [:tbody]
143 | (map-indexed
144 | (fn [idx {:strs [id]
145 | {:strs [viewCount likeCount commentCount]} "statistics"
146 | {:strs [title] {{:strs [url]} "default"} "thumbnails"} "snippet"}]
147 | [:tr
148 | [:td {:align "right"
149 | :style {:padding "10px"}} (inc idx)]
150 | [:td {:style {:padding "10px"}} title]
151 | [:td {:align "right"
152 | :style {:padding "10px"}} viewCount]
153 | [:td {:align "right"
154 | :style {:padding "10px"}} likeCount]
155 | [:td {:align "right"
156 | :style {:padding "10px"}} commentCount]
157 | [:td {:style {:padding "10px"}}
158 | [:a {:href (str "https://youtube.com/watch?v=" id) :target "_blank"}
159 | [:img {:src url :height 50}]]]])
160 | videos))]))
161 |
162 | ;; ### Most viewed
163 |
164 | (video-summary (last (sort-by #(get-in % ["statistics" "viewCount"]) videos)))
165 |
166 | ;; The first thing that jumps out at us is that one talk received about 300k views.
167 | ;; It is the famous [Hammock Driven Development](https://www.youtube.com/watch?v=f84n5oFoZBc) talk by Rich Hickey.
168 | ;; I speculate that this talk is especially popular because it tackles broad topics of programming methodologies,
169 | ;; problem-solving, and thinking.
170 | ;; My favourite part is [when he jokes about the Agile sprint, sprint, sprint approach](https://www.youtube.com/watch?v=zPT-DuG0UjU).
171 | ;; His talk provides a refreshing contrast to the Agile formula for success.
172 | ;; If I were to try to categorize this talk I might be tempted toward the label "lifehacks",
173 | ;; but that severely undersells it.
174 | ;; This talk works so well because it shows us who Rich is.
175 | ;; We are pulled in by the beliefs, practices, and lifestyle of the person.
176 | ;; I hope we see more talks at Conj that go outside the box of Clojure.
177 |
178 | ;; ### Top 20 most viewed
179 |
180 | (video-table (take 20 (reverse (sort-by #(get-in % ["statistics" "viewCount"]) videos))))
181 |
182 | ;; Many of the most viewed Clojure talks were keynotes delivered by heavy hitters Rich Hickey, Brian Goetz, and Guy Steele.
183 | ;; More interesting is that one talk sticks out as very different.
184 | ;; "Every Clojure Talk Ever - Alex Engelberg and Derek Slager" comes in at #7,
185 | ;; and is the complete opposite of the serious and impressive topics surrounding it.
186 | ;; I remember listening to this talk, and it hit close to home for me, leaving me with mixed feelings.
187 | ;; It helped me to stop taking myself so seriously.
188 | ;; My humor has since improved and I now enjoy the spirit in which the talk was delivered.
189 | ;; Clearly Clojurists enjoyed the chance to laugh a little and reflect.
190 | ;; There aren't many Clojure talks that lean this heavily into comedy.
191 | ;; I hope we see a few more presenters take on the challenge to make the audience laugh.
192 |
193 | ;; ### Most liked
194 |
195 | ;; Likes are not available on all videos (for example the most viewed video has private likes).
196 | ;; The owner of the channel can see all likes (and dislikes), but we the public don't.
197 |
198 | (count (filter #(get-in % ["statistics" "likeCount"]) videos))
199 |
200 | ;; Only about half the talks have likes visible, so we might be missing some well liked videos.
201 |
202 | (video-summary (last (sort-by #(get-in % ["statistics" "likeCount"]) videos)))
203 |
204 | ;; "Every Clojure Talk Ever" comes in at #1 liked,
205 | ;; supporting the notion that people enjoy talks that go outside the box and embrace comedy.
206 |
207 | ;; ### Top 20 most liked
208 |
209 | (video-table (take 20 (reverse (sort-by #(get-in % ["statistics" "likeCount"]) videos))))
210 |
211 | ;; There is a lot of overlap between viewed and liked.
212 | ;; At #5 "Code goes in, Art comes out - Tyler Hobbs" is another talk that goes outside the box to show beautiful art works.
213 |
214 | ;; ### Most discussed
215 |
216 | (video-summary (last (sort-by #(get-in % ["statistics" "commentCount"]) videos)))
217 |
218 | ;; "Maybe Not" is a talk I had to watch 3 times to digest.
219 | ;; Type theory is the ultimate CompSci topic that people have strong thoughts on,
220 | ;; so there is more comment chatter on this talk.
221 | ;; This talk is very much "in the box"; about technology, computation theory, and the space Clojure occupies in Type theory.
222 | ;; This is a good reminder of the engaging nature of Clojure technology oriented deep dives.
223 |
224 | ;; ### Top 20 most discussed
225 |
226 | (video-table (take 20 (reverse (sort-by #(get-in % ["statistics" "commentCount"]) videos))))
227 |
228 | ;; The first not by Rich Hickey talk on this list is "Bruce Hauman - Developing ClojureScript With Figwheel",
229 | ;; a fun and engaging talk that presents the wonderful powers of automatic code loading for interactive development.
230 |
231 | ;; ### Hidden gems
232 |
233 | ;; Talks that have a high like:view ratio may indicate they have interesting content.
234 | ;; Again, this only works for the 50% of videos that have likes visible.
235 |
236 | (defn like-ratio [{{:strs [likeCount viewCount]} "statistics"}]
237 | (when likeCount
238 | (/ likeCount viewCount)))
239 |
240 | (video-summary (last (sort-by like-ratio videos)))
241 |
242 | ;; I hadn't seen the Clojure 1.11 chat before, and watching it through, I'm glad I discovered it.
243 | ;; It's quite different from the talks, as it is more of an informal but deep dive into implementation changes in Clojure.
244 | ;; The core team discuss several issues in all their gory technical detail.
245 | ;; Alex, if you read this, I hope that seeing the high like ratio encourages you to keep posting these updates.
246 |
247 | (video-table (take 20 (reverse (sort-by like-ratio videos))))
248 |
249 | ;; There are many talks on this list that I remember enjoying, which makes me think this can be a helpful metric.
250 | ;; This list turned up some talks that I hadn't watched before;
251 | ;; I enjoyed watching "From Lazy Lisper to Confident Clojurist - Alexander Oloo" for the first time,
252 | ;; and appreciated his conclusions about building communities and choosing problems you care about.
253 |
254 | ;; I was overjoyed to see "How to transfer Clojure goodness to other languages" by Elango Cheran and Timothy Pratley came in at #8 by this metric.
255 |
256 | ;; ### Other ideas
257 |
258 | ;; We've used some obvious metrics to gain some insights into previous talks on ClojureTV.
259 | ;; I think there are deeper analysis that could be performed perhaps using automated feature detection.
260 | ;; It might also be cool to see how this compares to "Strange Loop" videos.
261 | ;; If you are interested in diving deeper with this dataset, or perhaps trying the same investigation for your favorite channel,
262 | ;; the good news is that you can adapt this notebook from the sourcecode.
263 |
264 | ;; ## Aside about accessing YouTube data with HappyGAPI2
265 |
266 | ;; This article uses HappyGAPI2 to call the YouTube API.
267 | ;; I created HappyGAPI about 4 years ago because I wanted to update spreadsheets automatically.
268 | ;; At the time there weren't many (any?) good alternatives for using OAuth2 and consequently GAPI from Clojure.
269 | ;; It made my life easier.
270 | ;; But... I made a few design mistakes which left the implementation rigid.
271 | ;; Recently I spent some time addressing those to make a new more flexible thing called HappyAPI.
272 |
273 | ;; The main goals of HappyAPI are:
274 |
275 | ;; 1. Untangle the OAuth2 client as a library usable in other APIs (not just Google)
276 | ;; 2. Pluggable with other http clients and json encoder/decoders
277 | ;; 3. Easier to use
278 | ;; 3.1. Better organization; one namespace per api, and required arguments as function parameters
279 | ;; 3.2. Don't require users to call `(auth!)`
280 | ;; 3.3. Automate multiple page result retrieval
281 | ;; 3.4. Better docstrings
282 |
283 | ;; I'm happy to say that the new design seems to work well.
284 | ;; But these are breaking changes.
285 | ;; As a result I intend to release a newly named version of HappyGAPI that depends on HappyAPI as a library.
286 | ;; What should I call it? Perhaps `io.github.timothypratley/happyapi` and `io.github.timothypratley/happygapi2`?
287 |
288 | ;; I've put alpha jars on Clojars.
289 | ;; I'd like to get some feedback on the new design, and try to avoid future breaking changes.
290 | ;; Please let me know what you think.
291 | ;; If you have the time, a review of the code at https://github.com/timothypratley/happyapi would be very helpful!
292 |
293 |
294 | ;; ## Conclusion
295 |
296 | ;; We explored the popularity of ClojureTV YouTube videos.
297 | ;; Grabbing data from Google APIs is easier now thanks to HappyGAPI2.
298 |
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 4.0.0
4 | io.github.timothypratley
5 | happyapi
6 | jar
7 | happyapi
8 | Middleware oriented oauth2 client for webservices
9 | http://github.com/timothypratley/happyapi
10 |
11 |
12 | EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0
13 | https://www.eclipse.org/legal/epl-2.0/
14 |
15 |
16 |
17 | https://github.com/timothypratley/happyapi
18 | scm:git:git://github.com/timothypratley/happyapi.git
19 | scm:git:ssh://git@github.com/timothypratley/happyapi.git
20 |
21 |
22 | src
23 | test
24 |
25 |
26 | resources
27 |
28 |
29 |
30 |
31 | resources
32 |
33 |
34 | target
35 | target/classes
36 |
37 |
38 |
39 |
40 | clojars
41 | https://repo.clojars.org/
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | org.clojure
50 | clojure
51 | 1.11.3
52 |
53 |
54 | com.grzm
55 | uri-template
56 | 0.7.1
57 |
58 |
59 | buddy
60 | buddy-sign
61 | 3.5.351
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/resources/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/timothypratley/happyapi/3109177d3276a0a65b63591eba7837ac0e50f379/resources/favicon.ico
--------------------------------------------------------------------------------
/src/happyapi/apikey/client.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.apikey.client
2 | (:require [happyapi.middleware :as middleware]))
3 |
4 | (defn make-client
5 | "Given a config map
6 |
7 | {:apikey
8 | :fns {#{:request :query-string :encode :decode} }
9 | :keywordize-keys }
10 |
11 | returns a wrapped request function."
12 | [{:as config
13 | :keys [apikey keywordize-keys]
14 | {:keys [request]} :fns}]
15 | (when-not (middleware/fn-or-var? request)
16 | (throw (ex-info "request must be a function or var"
17 | {:id ::request-must-be-a-function
18 | :request request
19 | :config config})))
20 | (-> request
21 | (middleware/wrap-cookie-policy-standard)
22 | (middleware/wrap-informative-exceptions)
23 | (middleware/wrap-json config)
24 | (middleware/wrap-apikey-auth apikey)
25 | (middleware/wrap-uri-template)
26 | (middleware/wrap-paging)
27 | (middleware/wrap-extract-result)
28 | (middleware/wrap-keywordize-keys keywordize-keys)))
29 |
--------------------------------------------------------------------------------
/src/happyapi/deps.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.deps
2 | "Presents an interface to performing http requests and json encoding/decoding.
3 | Allows easy selection of and migration between libraries that provide these features
4 | and avoids pulling in unnecessary dependencies.
5 | Dependencies are required upon selection.")
6 |
7 | (defn resolve-fn
8 | "Requires, resolves, and derefs to a function identified by sym, will throw on failure"
9 | [sym]
10 | (try (require (symbol (namespace sym)))
11 | (catch Throwable ex
12 | (throw (ex-info (str "Failed to require " sym " - are you missing a dependency?")
13 | {:id ::dependency-ns-not-found
14 | :sym sym}
15 | ex))))
16 | (or (some-> (ns-resolve *ns* sym)
17 | (deref))
18 | (throw (ex-info (str "Failed to find " sym " in " (namespace sym))
19 | {:id ::dependency-fn-not-found
20 | :sym sym}))))
21 |
22 | (defmulti require-dep "Resolves a provider keyword to the functions it provides" identity)
23 |
24 | (defmethod require-dep :httpkit [_]
25 | {:request (let [httpkit-request (resolve-fn 'org.httpkit.client/request)]
26 | (fn request
27 | ([args] @(httpkit-request args))
28 | ([args respond raise]
29 | (httpkit-request args (fn callback [response]
30 | ;; httpkit doesn't raise, it just puts errors in the response
31 | (if (contains? response :error)
32 | (raise (ex-info "ERROR in response"
33 | {:id ::error-in-response
34 | :resp response}))
35 | (respond response)))))))
36 | :query-string (resolve-fn 'org.httpkit.client/query-string)
37 | :run-server (let [run (resolve-fn 'org.httpkit.server/run-server)
38 | port (resolve-fn 'org.httpkit.server/server-port)
39 | stop! (resolve-fn 'org.httpkit.server/server-stop!)]
40 | (fn httpkit-run-server [handler config]
41 | (let [server (run handler (assoc config :legacy-return-value? false))]
42 | {:port (port server)
43 | :stop (fn [] (stop! server {:timeout 100}))})))})
44 | (defmethod require-dep :jetty [_]
45 | {:run-server (let [run (resolve-fn 'ring.adapter.jetty/run-jetty)]
46 | (fn jetty-run-server [handler config]
47 | (let [server (run handler (assoc config :join? false))]
48 | (.setStopTimeout server 100)
49 | {:port (-> server .getConnectors first .getLocalPort)
50 | :stop (fn stop-jetty []
51 | (.stop server))})))})
52 | (defmethod require-dep :clj-http [_]
53 | {:request (resolve-fn 'clj-http.client/request)
54 | :query-string (resolve-fn 'clj-http.client/generate-query-string)})
55 | (defmethod require-dep :clj-http.lite [_]
56 | {:request (resolve-fn 'clj-http.lite.client/request)
57 | :query-string (resolve-fn 'clj-http.lite.client/generate-query-string)})
58 |
59 | (defmethod require-dep :cheshire [_]
60 | {:encode (resolve-fn 'cheshire.core/generate-string)
61 | :decode (resolve-fn 'cheshire.core/parse-string)})
62 | (defmethod require-dep :jsonista [_]
63 | {:encode (resolve-fn 'jsonista.core/write-value-as-string)
64 | :decode (resolve-fn 'jsonista.core/read-value)})
65 | (defmethod require-dep :data.json [_]
66 | {:encode (resolve-fn 'clojure.data.json/write-str)
67 | :decode (resolve-fn 'clojure.data.json/read-str)})
68 | (defmethod require-dep :charred [_]
69 | {:encode (resolve-fn 'charred.api/read-json)
70 | :decode (resolve-fn 'charred.api/write-json-str)})
71 |
72 | (defn possible
73 | "Returns the valid keys for `choose` and `require-deps`."
74 | []
75 | (set (keys (methods require-dep))))
76 |
77 | (defn choose
78 | "Requires dependency providers, and returns a map containing functions.
79 | See `possible-providers` for valid inputs."
80 | ([ks] (apply merge (map require-dep ks))))
81 |
82 | (defn present
83 | "For informative purposes only.
84 | Shows what dependencies are available.
85 | Will attempt to require all possible dependency providers."
86 | []
87 | (into {}
88 | (for [[k f] (methods require-dep)]
89 | [k (try (f k) (catch Throwable ex))])))
90 |
--------------------------------------------------------------------------------
/src/happyapi/middleware.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.middleware
2 | "Wrapping facilitates an abstract http-request rather than a specific implementation,
3 | and allows for configuration of cross-cutting concerns."
4 | (:require [clojure.string :as str]
5 | [clojure.walk :as walk]
6 | [com.grzm.uri-template :as uri-template]))
7 |
8 | (defn success?
9 | [{:keys [status]}]
10 | (and (number? status)
11 | (<= 200 status 299)))
12 |
13 | (defn fn-or-var?
14 | [f]
15 | (or (fn? f) (var? f)))
16 |
17 | (defn wrap-cookie-policy-standard [request]
18 | (fn
19 | ([args]
20 | (request (assoc args :cookie-policy :standard)))
21 | ([args respond raise]
22 | (request (assoc args :cookie-policy :standard) respond raise))))
23 |
24 | (defn informative-exception [id ex args]
25 | (ex-info (str "Failed " (or (some-> (:method args) (name) (str/upper-case))
26 | "no :method provided")
27 | " " (or (:url args) "no :url provided")
28 | " " (ex-message ex))
29 | {:id id
30 | :args args}
31 | ex))
32 |
33 | (defn wrap-informative-exceptions [request]
34 | (fn
35 | ([args]
36 | (try
37 | (request args)
38 | (catch Exception ex
39 | (throw (informative-exception ::request-failed ex args)))))
40 | ([args respond raise]
41 | (request args respond
42 | (fn [ex]
43 | (raise (informative-exception ::request-failed-async ex args)))))))
44 |
45 | (defn paging-interrupted [ex items]
46 | ;; items collected so far are added to the exception so that they may be retrieved.
47 | (if (seq items)
48 | (ex-info "Paging interrupted"
49 | {:id ::paging-interrupted
50 | :items items}
51 | ex)
52 | ex))
53 |
54 | (defn request-pages-async [request args respond raise items]
55 | (request args
56 | (fn [resp]
57 | (let [items (into items (get-in resp [:body "items"]))
58 | resp (assoc-in resp [:body "items"] items)
59 | nextPageToken (get-in resp [:body "nextPageToken"])]
60 | (if nextPageToken
61 | (request-pages-async request (assoc-in args [:query-params :pageToken] nextPageToken) respond raise items)
62 | (respond resp))))
63 | (fn [ex]
64 | (raise (paging-interrupted ex items)))))
65 |
66 | (defn request-pages [request args]
67 | (loop [page nil
68 | items []]
69 | (let [args (if page
70 | (assoc-in args [:query-params :pageToken] page)
71 | args)
72 | resp (try
73 | (request args)
74 | (catch Throwable ex
75 | (throw (paging-interrupted ex items))))
76 | items (into items (get-in resp [:body "items"]))
77 | resp (assoc-in resp [:body "items"] items)
78 | nextPageToken (get-in resp [:body "nextPageToken"])]
79 | (if nextPageToken
80 | (if (= page nextPageToken)
81 | (throw (paging-interrupted (ex-info "nextPageToken did not change while paging"
82 | {:id ::invalid-nextPageToken
83 | :nextPageToken nextPageToken})
84 | items))
85 | (recur nextPageToken items))
86 | resp))))
87 |
88 | ;; TODO: should there be a way to monitor progress and perhaps stop looping?
89 | ;; TODO: would it be interesting to provide a lazy iteration version? probably not, seems like a bad idea
90 | (defn wrap-paging
91 | "When fetching collections, will request all pages.
92 | This may take a long time.
93 | `wrap-paging` must come before `wrap-deitemize` when used together"
94 | [request]
95 | (fn paging
96 | ([args]
97 | (request-pages request args))
98 | ([args respond raise]
99 | (request-pages-async request args respond raise []))))
100 |
101 | (defn maybe-update [args k f & more]
102 | (if (contains? args k)
103 | (apply update args k f more)
104 | args))
105 |
106 | (defn enjsonize [args encode]
107 | (-> (maybe-update args :body encode)
108 | (update :headers merge {"Content-Type" "application/json"
109 | "Accept" "application/json"})))
110 |
111 | (defn json? [resp]
112 | (some->> (get-in resp [:headers :content-type])
113 | (re-find #"^application/(.+\+)?json")))
114 |
115 | (defn dejsonize [args resp decode keywordize-keys]
116 | (if (json? resp)
117 | (maybe-update resp :body #(cond-> (decode %)
118 | (and (not (false? (:keywordize-keys args)))
119 | (or keywordize-keys (:keywordize-keys args)))
120 | (walk/keywordize-keys)))
121 | resp))
122 |
123 | (defn wrap-json
124 | "Converts the body of responses to a data structure.
125 | Pluggable json implementations resolved from dependencies, or can be passed as an argument.
126 | Keywordization can be enabled with :keywordize-keys true.
127 |
128 | Error responses don't throw exceptions when parsing fails.
129 | Success responses that fail to parse are rethrown with the response and request as context."
130 | [request {:as config
131 | :keys [keywordize-keys]
132 | {:keys [encode decode]} :fns}]
133 | (when-not (and (fn-or-var? encode)
134 | (fn-or-var? decode))
135 | (throw (ex-info "JSON dependency invalid"
136 | {:id ::json-dependency-invalid
137 | :encode encode
138 | :decode decode
139 | :config config})))
140 | (fn
141 | ([args]
142 | (let [args (enjsonize args encode)
143 | resp (request args)]
144 | (try
145 | (dejsonize args resp decode keywordize-keys)
146 | (catch Throwable ex
147 | (if (success? resp)
148 | (throw (ex-info "Failed to json decode the body of a successful response"
149 | {:id ::parse-json-failed
150 | :response resp
151 | :args args}
152 | ex))
153 | ;; errors often have non-json bodies, presumably users want to handle those if we got here
154 | resp)))))
155 | ([args respond raise]
156 | (request (-> (enjsonize args encode))
157 | (fn [resp]
158 | (try
159 | (-> (dejsonize args resp decode keywordize-keys)
160 | (respond))
161 | (catch Throwable ex
162 | (if (success? resp)
163 | (raise (ex-info "Failed to json decode the body of a successful async response"
164 | {:id ::parse-json-failed-async
165 | :response resp
166 | :args args}
167 | ex))
168 | ;; errors often have non-json bodies, presumably users want to handle those if we got here
169 | (respond resp)))))
170 | raise))))
171 |
172 | ;; TODO: surely there are other cases to consider?
173 | (defn remove-redundant-data-labels [x]
174 | (if (map? x)
175 | (cond (contains? x "data") (recur (get x "data"))
176 | (seq (get x "items")) (mapv remove-redundant-data-labels (get x "items"))
177 | :else x)
178 | x))
179 |
180 | (defn extract-result [{:keys [body]}]
181 | (remove-redundant-data-labels body))
182 |
183 | (defn wrap-extract-result
184 | "When we call an API, we want the logical result of the call, not the map containing body, and status.
185 | We also don't need to preserve the type of arrays, so we can remove that layer of indirection (:items is unnecessary).
186 | When using this middleware, you should also use a client or middleware that throws when status indicates failure,
187 | to prevent logical results when there is an error."
188 | [request]
189 | (fn
190 | ([args]
191 | (extract-result (request args)))
192 | ([args respond raise]
193 | (request args
194 | (fn [resp]
195 | (respond (extract-result resp)))
196 | raise))))
197 |
198 | (defn maybe-keywordize-keys [args resp keywordize-keys]
199 | (if (and (not (false? (:keywordize-keys args)))
200 | (or keywordize-keys (:keywordize-keys args)))
201 | (walk/keywordize-keys resp)
202 | resp))
203 |
204 | (defn wrap-keywordize-keys [request keywordize-keys]
205 | (fn
206 | ([args]
207 | (maybe-keywordize-keys args (request args) keywordize-keys))
208 | ([args respond raise]
209 | (request args
210 | (fn [resp]
211 | (respond (maybe-keywordize-keys args resp keywordize-keys)))
212 | raise))))
213 |
214 | (defn uri-from-template [{:as args :keys [uri-template uri-template-args]}]
215 | (if uri-template
216 | (assoc args :url (uri-template/expand uri-template uri-template-args))
217 | args))
218 |
219 | (defn wrap-uri-template
220 | "Arguments to APIs may appear in the url path, query-string, or body of a request.
221 | This middleware assists with the correct application of path arguments.
222 | When :uri-template is present, it adds :url which is the application of the template with :uri-template-args.
223 | See https://datatracker.ietf.org/doc/html/rfc6570 for more information about uri-templates."
224 | [request]
225 | (fn
226 | ([args] (request (uri-from-template args)))
227 | ([args respond raise] (request (uri-from-template args) respond raise))))
228 |
229 | ;; TODO: paging should save progress? or is it ok with informative exceptions?
230 | ;; TODO: metering? Seeing as this is a pass through wrapper, just recommend that library right?!
231 |
232 | (defn apikey-param
233 | "Given credentials, returns a header suitable for merging into a request."
234 | [args apikey]
235 | (assoc-in args [:query-params "key"] apikey))
236 |
237 | (defn bearer-header
238 | [args bearer]
239 | (assoc-in args [:headers "Authorization"] (str "Bearer " bearer)))
240 |
241 | (defn wrap-apikey-auth [request apikey]
242 | {:pre [(string? apikey)]}
243 | (fn
244 | ([args]
245 | (request (apikey-param args apikey)))
246 | ([args respond raise]
247 | (request (apikey-param args apikey) respond raise))))
248 |
249 | (defn wrap-debug [request]
250 | (fn
251 | ([args]
252 | (println "DEBUG request: " args)
253 | (doto (request args)
254 | (->> (println "DEBUG response:"))))
255 | ([args response raise]
256 | (println "DEBUG async request: " args)
257 | (request args
258 | (fn [resp]
259 | (println "DEBUG async response: " resp)
260 | (response resp))
261 | raise))))
262 |
--------------------------------------------------------------------------------
/src/happyapi/oauth2/auth.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.oauth2.auth
2 | "Helpers for getting an OAuth 2.0 token.
3 | See https://developers.google.com/identity/protocols/OAuth2WebServer"
4 | (:require [clojure.string :as str]
5 | [clojure.set :as set]
6 | [buddy.sign.jwt :as jwt]
7 | [buddy.core.keys :as keys]
8 | [happyapi.middleware :as middleware])
9 | (:import (java.util Date)
10 | (java.util Base64)))
11 |
12 | (set! *warn-on-reflection* true)
13 |
14 | (defn provider-login-url
15 | "Step 1: Set authorization parameters.
16 | Builds the URL to send the user to for them to authorize your app.
17 | For local testing you can paste this URL into your browser,
18 | or call (clojure.java.browse/browse-url (provider-login-url my-config scopes optional)).
19 | In your app you need to send your user to this URL, usually with a redirect response.
20 | For valid optional params, see https://developers.google.com/identity/protocols/oauth2/web-server#httprest_1,
21 | noting that `state` is strongly recommended."
22 | ([config scopes] (provider-login-url config scopes nil))
23 | ([{:as config
24 | :keys [auth_uri client_id redirect_uri]
25 | {:keys [query-string]} :fns}
26 | scopes
27 | optional]
28 | (let [params (merge {:client_id client_id
29 | :response_type "code"
30 | :redirect_uri redirect_uri
31 | :scope (str/join " " scopes)}
32 | optional)]
33 | (str auth_uri "?" (query-string params)))))
34 |
35 | ;; Step 2: Redirect to Google's OAuth 2.0 server.
36 |
37 | ;; Step 3: Google prompts user for consent.
38 | ;; Sit back and wait.
39 | ;; There should be a route in your app to handle the redirect from Google (see step 4).
40 | ;; happyapioauth2-capture-redirect shows how you could do this,
41 | ;; and is useful if you don't want to run a server.
42 |
43 | ;; Step 4: Handle the OAuth 2.0 server response
44 |
45 | (defn with-timestamp
46 | "The server won't give us the time of day, so let's check our clock."
47 | [{:as credentials :keys [expires_in]}]
48 | (if expires_in
49 | (assoc credentials
50 | :expires_at (Date. ^long (+ (* expires_in 1000) (System/currentTimeMillis))))
51 | credentials))
52 |
53 | (defn base64 [^String to-encode]
54 | (.encodeToString (Base64/getEncoder) (.getBytes to-encode)))
55 |
56 | (defn exchange-code
57 | "Step 5: Exchange authorization code for refresh and access tokens.
58 | When the user is redirected back to your app from Google with a short-lived code,
59 | exchange the code for a long-lived access token."
60 | [request
61 | {:as config :keys [token_uri client_id client_secret redirect_uri]}
62 | code
63 | code_verifier]
64 | (let [resp (request {:method :post
65 | :url token_uri
66 | ;; Google documentation says client_id and client_secret should be parameters,
67 | ;; but accepts them in the Basic Auth header (undocumented).
68 | ;; Other providers require them as Basic Auth header.
69 | :headers {"Authorization" (str "Basic " (base64 (str client_id ":" client_secret)))}
70 | ;; RFC 6749: form encoded params
71 | :form-params (cond-> {:code code
72 | :grant_type "authorization_code"
73 | :redirect_uri redirect_uri}
74 | code_verifier (assoc :code_verifier code_verifier))
75 | :keywordize-keys true})]
76 | (when (middleware/success? resp)
77 | (with-timestamp (:body resp)))))
78 |
79 | (defn refresh-credentials
80 | "Given a config map, and a credentials map containing either a refresh_token or private_key,
81 | fetches a new access token.
82 | Returns credentials if successful (a map containing an access token).
83 | Refresh tokens eventually expire, and attempts to refresh will fail with 401.
84 | Therefore, calls that could cause a refresh should catch 401 exceptions,
85 | call set-authorization-parameters and redirect."
86 | [request
87 | {:as config :keys [token_uri client_id client_secret client_email private_key]}
88 | scopes
89 | {:as credentials :keys [refresh_token]}]
90 | (try
91 | (let [now (quot (.getTime (Date.)) 1000)
92 | params (cond private_key
93 | {:grant_type "urn:ietf:params:oauth:grant-type:jwt-bearer"
94 | :assertion (jwt/sign
95 | {:iss client_email,
96 | :scope (str/join " " scopes),
97 | :aud token_uri
98 | :exp (+ now 3600)
99 | :iat now}
100 | (keys/str->private-key private_key)
101 | {:alg :rs256
102 | :header {:alg "RS256"
103 | :typ "JWT"}})}
104 |
105 | refresh_token
106 | {:client_id client_id
107 | :client_secret client_secret
108 | :grant_type "refresh_token"
109 | :refresh_token refresh_token}
110 |
111 | :else (throw (ex-info "Refresh missing token"
112 | {:id ::refresh-missing-token})))
113 | resp (request {:url token_uri
114 | :method :post
115 | :form-params params
116 | :keywordize-keys true})]
117 | (when (middleware/success? resp)
118 | (with-timestamp (:body resp))))
119 | (catch Exception ex
120 | ;; TODO: should probably only swallow 401?
121 | )))
122 |
123 | (defn revoke-token
124 | "Given a credentials map containing either an access token or refresh token, revokes it."
125 | [request
126 | {:as config :keys [token_uri]}
127 | {:as credentials :keys [access_token refresh_token]}]
128 | (request {:method :post
129 | :url (str/replace token_uri #"/token$" "/revoke")
130 | ;; may be form-params or json body
131 | :body {"token" (or access_token
132 | refresh_token
133 | (throw (ex-info "Credentials missing token"
134 | {:id ::credentials-missing-token
135 | :credentials credentials})))}
136 | :keywordize-keys true}))
137 |
138 | (defn valid? [{:as credentials :keys [expires_at access_token]}]
139 | (boolean
140 | (and access_token
141 | (or (not expires_at)
142 | (neg? (.compareTo (Date.) expires_at))))))
143 |
144 | (defn refreshable? [{:as config :keys [private_key]} {:as credentials :keys [refresh_token]}]
145 | (boolean (or refresh_token private_key)))
146 |
147 | (defn credential-scopes [credentials]
148 | (set (some-> (:scope credentials) (str/split #" "))))
149 |
150 | (defn has-scopes? [credentials scopes]
151 | ;; TODO: scopes have a hierarchy
152 | (set/subset? (set scopes) (credential-scopes credentials)))
153 |
--------------------------------------------------------------------------------
/src/happyapi/oauth2/capture_redirect.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.oauth2.capture-redirect
2 | "Reference for receiving a token in a redirect from the oauth provider.
3 | If you are making a web app, implement a route in your app that captures the code parameter.
4 | If you use this namespace, add ring as a dependency in your project."
5 | (:require [clojure.java.browse :as browse]
6 | [clojure.java.io :as io]
7 | [clojure.set :as set]
8 | [happyapi.middleware :as middleware]
9 | [happyapi.oauth2.auth :as oauth2]
10 | [ring.middleware.params :as params]))
11 |
12 | (set! *warn-on-reflection* true)
13 |
14 | (def login-timeout
15 | "If the user doesn't log in after 2 minutes, stop waiting."
16 | (* 2 60 1000))
17 |
18 | (defn browse-to-provider [config scopes optional]
19 | (-> (oauth2/provider-login-url config scopes optional)
20 | (browse/browse-url)))
21 |
22 | (defn make-redirect-handler [p]
23 | (-> (fn redirect-handler [{:as req :keys [request-method uri params]}]
24 | (case [request-method uri]
25 | [:get "/favicon.ico"] {:body (io/file (io/resource "favicon.ico"))
26 | :status 200}
27 | (if (get @(deliver p params) "code")
28 | {:status 200
29 | :body "Code received, authentication successful."}
30 | {:status 400
31 | :body "No code in response."})))
32 | (params/wrap-params)))
33 |
34 | (defn fresh-credentials
35 | "Opens a browser to authenticate, waits for a redirect, and returns a code.
36 | Defaults access_type to offline,
37 | state to a random uuid which is checked when redirected back,
38 | and include_granted_scopes true."
39 | [request {:as config :keys [redirect_uri authorization_options fns]} scopes]
40 | (let [p (promise)
41 | [match protocol host _ requested-port path] (re-find #"^(https?://)(localhost|127.0.0.1)(:(\d+))?(/.*)?$" redirect_uri)
42 | _ (when-not match
43 | (throw (ex-info "redirect_uri should match http://localhost"
44 | {:id ::bad-redirect-uri
45 | :redirect_uri redirect_uri
46 | :config config})))
47 |
48 | ;; Port 80 is a privileged port that requires root permissions, which may be problematic for some users.
49 | ;; Google allows the redirect_uri port to vary, other providers do not.
50 | ;; A random (or specified) port is a natural choice.
51 | ;; Put port 0 in the redirect_uri to activate random port selection.
52 | port (if requested-port
53 | (Integer/parseInt requested-port)
54 | 80)
55 | {:keys [run-server]} fns
56 | {:keys [port stop]} (run-server (make-redirect-handler p) {:port port})
57 | ;; The port may have changed when requesting a random port
58 | config (if requested-port
59 | (assoc config :redirect_uri (str protocol host ":" port path))
60 | config)
61 | ;; Twitter requires a PKCE challenge.
62 | ;; Challenges are for the provider server checking, state is for client checking.
63 | ;; We use the same value for both state and challenge.
64 | state-and-challenge (str (random-uuid))
65 | {:keys [code_challenge_method]} authorization_options
66 | ;; access_type offline and prompt consent together result in a refresh token (that both are necessary is undocumented by Google afaik)
67 | ;; most web-apps wouldn't do this, it is intended for desktop apps, which is the anticipated usage of this namespace
68 | optional (merge authorization_options
69 | {:state state-and-challenge}
70 | (when code_challenge_method
71 | {:code_challenge state-and-challenge}))
72 | ;; send the user to the provider to authenticate and authorize.
73 | ;; this url includes the redirect_uri as a query param,
74 | ;; so we must provide port chosen by our local server
75 | _ (browse-to-provider config scopes optional)
76 | ;; wait for the user to get redirected to localhost with a code
77 | {:strs [code state] :as return-params} (deref p login-timeout nil)]
78 | (stop)
79 | (if code
80 | (do
81 | (when-not (= state state-and-challenge)
82 | (throw (ex-info "Redirected state does not match request"
83 | {:id ::redirected-state-mismatch
84 | :optional optional
85 | :return-params return-params})))
86 | ;; exchange the code with the provider for credentials
87 | ;; (must have the same config as browse, the redirect_uri needs the correct port)
88 | (oauth2/exchange-code request config code (when code_challenge_method state-and-challenge)))
89 | (throw (ex-info "Login timeout, no code received."
90 | {:id ::login-timeout})))))
91 |
92 | (defn update-credentials
93 | "Use credentials if valid, refresh if necessary, or get new credentials.
94 | For valid optional params, see https://developers.google.com/identity/protocols/oauth2/web-server#httprest_1"
95 | ([request config credentials scopes]
96 | ;; scopes can grow
97 | (let [scopes (set/union (oauth2/credential-scopes credentials) (set scopes))]
98 | ;; merge to retain refresh token
99 | (merge credentials
100 | (or
101 | ;; already have valid credentials
102 | (and (oauth2/valid? credentials)
103 | (oauth2/has-scopes? credentials scopes)
104 | credentials)
105 | ;; try to refresh existing credentials
106 | (and (oauth2/refreshable? config credentials)
107 | (oauth2/has-scopes? credentials scopes)
108 | (oauth2/refresh-credentials (middleware/wrap-keywordize-keys request true) config scopes credentials))
109 | ;; new credentials required
110 | (fresh-credentials (middleware/wrap-keywordize-keys request true) config scopes))))))
111 |
--------------------------------------------------------------------------------
/src/happyapi/oauth2/client.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.oauth2.client
2 | (:require [clojure.string :as str]
3 | [happyapi.middleware :as middleware]
4 | [happyapi.oauth2.capture-redirect :as capture-redirect]
5 | [happyapi.oauth2.credentials :as credentials]))
6 |
7 | (def required-config [:provider :client_id :client_secret :auth_uri :token_uri :redirect_uri])
8 |
9 | (defn missing-config [config]
10 | (seq (for [k required-config
11 | :when (not (get config k))]
12 | k)))
13 |
14 | (defmulti endpoints identity)
15 | (defmethod endpoints :default [_] nil)
16 | (defmethod endpoints :google [_]
17 | {:auth_uri "https://accounts.google.com/o/oauth2/auth"
18 | :token_uri "https://oauth2.googleapis.com/token"
19 | ;; port 0 selects a random port
20 | :redirect_uri "http://localhost:0/redirect"
21 | :authorization_options {:access_type "offline"
22 | :prompt "consent"
23 | :include_granted_scopes true}})
24 | (defmethod endpoints :github [_]
25 | {:auth_uri "https://github.com/login/oauth/authorize"
26 | :token_uri "https://github.com/login/oauth/access_token"
27 | ;; port 0 selects a random port
28 | :redirect_uri "http://localhost:0/redirect"})
29 | (defmethod endpoints :twitter [_]
30 | {:auth_uri "https://twitter.com/i/oauth2/authorize"
31 | :token_uri "https://api.twitter.com/2/oauth2/token"
32 | :redirect_uri "http://localhost:8080/redirect"
33 | :authorization_options {:code_challenge_method "plain"}})
34 |
35 | (defn with-endpoints
36 | "The only configuration required is to know the provider (for endpoints),
37 | the client-id and the client-secret.
38 | This helper adds the endpoints for a given provider."
39 | [{:as config :keys [provider]}]
40 | (if provider
41 | (merge (endpoints provider) config)
42 | config))
43 |
44 | (defn oauth2
45 | "Performs a http-request that includes oauth2 credentials.
46 | Will obtain fresh credentials prior to the request if necessary.
47 | See https://developers.google.com/identity/protocols/oauth2 for more information."
48 | [request args config]
49 | (let [{:keys [provider]} config
50 | {:keys [user scopes] :or {user "user" scopes (:scopes config)}} args
51 | credentials (credentials/read-credentials provider user)
52 | credentials (capture-redirect/update-credentials request config credentials scopes)
53 | {:keys [access_token]} credentials]
54 | (credentials/save-credentials provider user credentials)
55 | (if access_token
56 | (request (middleware/bearer-header args access_token))
57 | (throw (ex-info (str "Failed to obtain credentials for " user)
58 | {:id ::failed-credentials
59 | :user user
60 | :scopes scopes})))))
61 |
62 | (defn oauth2-async
63 | "Only the http request is asynchronous; reading, updating, or writing credentials is done synchronously.
64 | Asynchronously obtaining credentials is challenging because it may rely on waiting for a redirect back.
65 | This compromise allows for convenient usage, but means that calls may block while authorizing before
66 | the http request is made."
67 | [request args config respond raise]
68 | (let [{:keys [provider]} config
69 | {:keys [user scopes] :or {user "user" scopes (:scopes config)}} args
70 | credentials (credentials/read-credentials provider user)
71 | credentials (capture-redirect/update-credentials request config credentials scopes)
72 | {:keys [access_token]} credentials]
73 | (credentials/save-credentials provider user credentials)
74 | (if access_token
75 | (request (middleware/apikey-param args access_token) respond raise)
76 | (raise (ex-info (str "Async failed to obtain credentials for " user)
77 | {:id ::async-failed-credentials
78 | :user user
79 | :scopes scopes})))))
80 |
81 | (defn check [config]
82 | (when-let [ks (missing-config config)]
83 | (throw (ex-info (str "Invalid oauth2 config: missing " (str/join "," ks))
84 | {:id ::invalid-config
85 | :missing (vec ks)
86 | :config config})))
87 | config)
88 |
89 | (defn wrap-oauth2
90 | "Wraps a http-request function that uses keys user and scopes from args to authorize according to config."
91 | [request config]
92 | (let [config (with-endpoints config)]
93 | (when-let [ks (missing-config config)]
94 | (throw (ex-info (str "Invalid config: missing " (str/join "," ks))
95 | {:id ::invalid-config
96 | :missing (vec ks)
97 | :config config})))
98 | (when-not (middleware/fn-or-var? request)
99 | (throw (ex-info "request must be a function or var"
100 | {:id ::request-must-be-a-function
101 | :request request
102 | :request-type (type request)})))
103 | (fn
104 | ([args]
105 | (oauth2 request args config))
106 | ([args respond raise]
107 | (oauth2-async request args config respond raise)))))
108 |
109 | (defn make-client
110 | "Given a config map
111 |
112 | {#{:client_id :client_secret}
113 | :provider #{:google :amazon :github :twitter ...}
114 | #{:auth_uri :token_uri}
115 | :fns {#{:request :query-string :encode :decode} }
116 | :keywordize-keys }
117 |
118 | Returns a wrapped request function.
119 |
120 | If `provider` is known, then auth_uri and token_uri endpoints will be added to the config,
121 | otherwise expects `auth_uri` and `token_uri`.
122 | `provider` is required to namespace tokens, but is not restricted to known providers.
123 | Dependencies are passed as functions in `fns`."
124 | [{:as config :keys [keywordize-keys] {:keys [request]} :fns}]
125 | (when-not (middleware/fn-or-var? request)
126 | (throw (ex-info "request must be a function or var"
127 | {:id ::request-must-be-a-function
128 | :request request
129 | :config config})))
130 | (-> request
131 | (middleware/wrap-cookie-policy-standard)
132 | (middleware/wrap-informative-exceptions)
133 | (middleware/wrap-json config)
134 | (wrap-oauth2 config)
135 | (middleware/wrap-uri-template)
136 | (middleware/wrap-paging)
137 | (middleware/wrap-extract-result)
138 | (middleware/wrap-keywordize-keys keywordize-keys)))
139 |
--------------------------------------------------------------------------------
/src/happyapi/oauth2/credentials.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.oauth2.credentials
2 | "Reference for how to manage credentials.
3 | For a web app, you should implement something like this but use your database for credential storage.
4 | secret, scopes, fetch, save have default implementations that you can replace with init!"
5 | (:require [clojure.java.io :as io]
6 | [clojure.edn :as edn]))
7 |
8 | (set! *warn-on-reflection* true)
9 |
10 | ;; TODO: Nope, this needs to be provider specific (at least!)
11 |
12 | (def *credentials-cache
13 | (atom nil))
14 |
15 | (defn read-credentials [provider user]
16 | (or (get-in @*credentials-cache [provider user])
17 | (let [credentials-file (io/file "tokens" (name provider) (str user ".edn"))]
18 | (when (.exists credentials-file)
19 | (edn/read-string (slurp credentials-file))))))
20 |
21 | (defn delete-credentials [provider user]
22 | (swap! *credentials-cache update provider dissoc user)
23 | (.delete (io/file (io/file "tokens" (name provider)) (str user ".edn"))))
24 |
25 | (defn write-credentials [provider user credentials]
26 | (swap! *credentials-cache assoc-in [provider user] credentials)
27 | (spit (io/file (doto (io/file "tokens" (name provider)) (.mkdirs))
28 | (str user ".edn"))
29 | credentials))
30 |
31 | (defn save-credentials [provider user new-credentials]
32 | (when (not= (get-in @*credentials-cache [provider user]) new-credentials)
33 | (if new-credentials
34 | (write-credentials provider user new-credentials)
35 | (delete-credentials provider user))))
36 |
--------------------------------------------------------------------------------
/src/happyapi/providers/amazon.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.providers.amazon
2 | (:require [happyapi.setup :as setup]))
3 |
4 | (declare api-request)
5 |
6 | (defn set-request! [client]
7 | (alter-var-root #'api-request (constantly client)))
8 |
9 | (defn setup!
10 | "Changes `api-request` to be a configured client.
11 | config is provider specific,
12 | it should contain `:client_id` and `:client_secret` for oauth2,
13 | or `:apikey`.
14 | See config/make-client for more options."
15 | [config] (set-request! (setup/make-client (when config {:amazon config}) :amazon)))
16 |
17 | (defn api-request
18 | "A function to handle API requests.
19 | Can be configured with `setup!`.
20 | Will attempt to configure itself if not previously configured.
21 | May also be replaced by a custom stack of middleware constructed in a different way.
22 | This is what generated code invokes, which means that customizations here
23 | will be present in the generated interface."
24 | ([args]
25 | (setup! nil)
26 | (api-request args))
27 | ([args respond raise]
28 | (try
29 | (setup! nil)
30 | (api-request args respond raise)
31 | (catch Throwable ex
32 | (raise ex)))))
33 |
--------------------------------------------------------------------------------
/src/happyapi/providers/github.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.providers.github
2 | (:require [happyapi.setup :as setup]))
3 |
4 | (declare api-request)
5 |
6 | (defn set-request! [client]
7 | (alter-var-root #'api-request (constantly client)))
8 |
9 | (defn setup!
10 | "Changes `api-request` to be a configured client.
11 | config is provider specific,
12 | it should contain `:client_id` and `:client_secret` for oauth2,
13 | or `:apikey`.
14 | See config/make-client for more options."
15 | [config] (set-request! (setup/make-client (when config {:github config}) :github)))
16 |
17 | (defn api-request
18 | "A function to handle API requests.
19 | Can be configured with `setup!`.
20 | Will attempt to configure itself if not previously configured.
21 | May also be replaced by a custom stack of middleware constructed in a different way.
22 | This is what generated code invokes, which means that customizations here
23 | will be present in the generated interface."
24 | ([args]
25 | (setup! nil)
26 | (api-request args))
27 | ([args respond raise]
28 | (try
29 | (setup! nil)
30 | (api-request args respond raise)
31 | (catch Throwable ex
32 | (raise ex)))))
33 |
--------------------------------------------------------------------------------
/src/happyapi/providers/google.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.providers.google
2 | (:require [happyapi.setup :as setup]))
3 |
4 | (declare api-request)
5 |
6 | (defn set-request! [client]
7 | (alter-var-root #'api-request (constantly client)))
8 |
9 | (defn setup!
10 | "Changes `api-request` to be a configured client.
11 | config is provider specific,
12 | it should contain `:client_id` and `:client_secret` for oauth2,
13 | or `:apikey`.
14 | See config/make-client for more options."
15 | [config] (set-request! (setup/make-client (when config {:google config}) :google)))
16 |
17 | (defn api-request
18 | "A function to handle API requests.
19 | Can be configured with `setup!`.
20 | Will attempt to configure itself if not previously configured.
21 | May also be replaced by a custom stack of middleware constructed in a different way.
22 | This is what generated code invokes, which means that customizations here
23 | will be present in the generated interface."
24 | ([args]
25 | (setup! nil)
26 | (api-request args))
27 | ([args respond raise]
28 | (try
29 | (setup! nil)
30 | (api-request args respond raise)
31 | (catch Throwable ex
32 | (raise ex)))))
33 |
--------------------------------------------------------------------------------
/src/happyapi/providers/twitter.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.providers.twitter
2 | (:require [happyapi.setup :as setup]))
3 |
4 | (declare api-request)
5 |
6 | (defn set-request! [client]
7 | (alter-var-root #'api-request (constantly client)))
8 |
9 | (defn setup!
10 | "Changes `api-request` to be a configured client.
11 | config is provider specific,
12 | it should contain `:client_id` and `:client_secret` for oauth2,
13 | or `:apikey`.
14 | See config/make-client for more options."
15 | [config] (set-request! (setup/make-client (when config {:twitter config}) :twitter)))
16 |
17 | (defn api-request
18 | "A function to handle API requests.
19 | Can be configured with `setup!`.
20 | Will attempt to configure itself if not previously configured.
21 | May also be replaced by a custom stack of middleware constructed in a different way.
22 | This is what generated code invokes, which means that customizations here
23 | will be present in the generated interface."
24 | ([args]
25 | (setup! nil)
26 | (api-request args))
27 | ([args respond raise]
28 | (try
29 | (setup! nil)
30 | (api-request args respond raise)
31 | (catch Throwable ex
32 | (raise ex)))))
33 |
--------------------------------------------------------------------------------
/src/happyapi/setup.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.setup
2 | (:require [clojure.edn :as edn]
3 | [clojure.java.io :as io]
4 | [clojure.walk :as walk]
5 | [happyapi.apikey.client :as apikey.client]
6 | [happyapi.deps :as deps]
7 | [happyapi.middleware :as middleware]
8 | [happyapi.oauth2.client :as oauth2.client]))
9 |
10 | (defn as-map
11 | "If a string, will coerce as edn.
12 | If given a map, will return it unchanged."
13 | [config]
14 | (cond (map? config) config
15 | (string? config) (let [{:keys [decode]} (deps/require-dep :cheshire)]
16 | (walk/keywordize-keys (decode config)))
17 | (nil? config) config
18 | :else (throw (ex-info "Unexpected configuration type"
19 | {:id ::unexpected-configuration-type
20 | :config-type (type config)
21 | :config config}))))
22 |
23 | (defn read-edn [s source]
24 | (try
25 | (edn/read-string s)
26 | (catch Throwable ex
27 | (throw (ex-info (str "Unreadable config, not EDN read from " (name source))
28 | {:id ::config-not-edn
29 | :source source
30 | :config s})))))
31 |
32 | ;; A standard way to search for config
33 | ;; check environment variables, files, resources, anything else?
34 | ;; should the logic be provider specific?
35 | ;; IDEA: The redirect server can request the client_secret, allowing you to use a password manager.
36 | (defn find-config
37 | "Looks for HAPPYAPI_CONFIG in the environment, then a file happyapi.edn,
38 | then happyapi.edn resource, else nil"
39 | []
40 | (let [config-file-name "happyapi.edn"]
41 | (or (some-> (System/getenv "HAPPYAPI_CONFIG") (read-edn :environment))
42 | (let [f (io/file config-file-name)]
43 | (when (.exists f)
44 | (-> (slurp f)
45 | (read-edn :file))))
46 | (when-let [r (io/resource config-file-name)]
47 | (-> (slurp r)
48 | (read-edn :resource))))))
49 |
50 | (defn with-deps
51 | "Selection of implementation functions for http and json,
52 | based on either the :deps or :fns of the config."
53 | [{:as config :keys [deps fns]}]
54 | (if deps
55 | (update config :fns #(merge (deps/choose deps) %))
56 | (when-not fns
57 | (throw (ex-info "Client configuration requires :deps like [:clj-http :cheshire] or :fns to be supplied"
58 | {:id ::config-missing-deps
59 | :config config})))))
60 |
61 | (defn resolve-fns
62 | [config]
63 | (update config :fns (fn [fns]
64 | (into fns (for [[k f] fns
65 | :when (qualified-symbol? f)]
66 | [k (deps/resolve-fn f)])))))
67 |
68 | (defn prepare-config
69 | "Prepares configuration by resolving dependencies.
70 | Checks that the necessary configuration is present, throws an exception if not.
71 | See docstring for make-client for typical configuration inputs."
72 | [provider config]
73 | (when-not provider
74 | (throw (ex-info "Provider is required"
75 | {:id ::provider-required
76 | :provider provider
77 | :config config})))
78 | (let [config (if (nil? config)
79 | (find-config)
80 | (as-map config))
81 | config (-> (get config provider)
82 | (update :provider #(or % provider))
83 | (with-deps)
84 | (resolve-fns))
85 | {:keys [client_id apikey fns]} config
86 | {:keys [request]} fns]
87 | (when-not (middleware/fn-or-var? request)
88 | (throw (ex-info "request must be a function or var"
89 | {:id ::request-must-be-a-function
90 | :request request
91 | :request-type (type request)})))
92 | (cond client_id (-> (oauth2.client/with-endpoints config)
93 | (oauth2.client/check))
94 | apikey config
95 | :else (throw (ex-info "Missing config, expected `:client_id` or `:apikey`"
96 | {:id ::missing-config
97 | :config config})))))
98 |
99 | (defn make-client
100 | "Returns a function that can make requests to an api.
101 |
102 | If `config` is nil, will attempt to find edn config in the environment HAPPYAPI_CONFIG,
103 | or a file happyapi.edn
104 |
105 | If specified, `config` should be a map:
106 |
107 | {:google {:deps [:clj-http :cheshire] ;; see happyapi.deps for alternatives
108 | :fns {...} ;; if you prefer to provide your own dependencies
109 | :client_id \"MY_ID\" ;; oauth2 client_id of your app
110 | :client_secret \"MY_SECRET\" ;; oauth2 client_secret from your provider
111 | :apikey \"MY_API_KEY\" ;; only when not using oauth2
112 | :scopes [] ;; optional default scopes used when none present in requests
113 | :keywordize-keys false ;; optional, defaults to true
114 | :provider :google}} ;; optional, use another provider urls and settings
115 |
116 | The `provider` argument is required and should match a top level key to use (other configs may be present)."
117 | [config provider]
118 | (let [config (prepare-config provider config)
119 | {:keys [client_id apikey]} config]
120 | (cond client_id (oauth2.client/make-client config)
121 | apikey (apikey.client/make-client config))))
122 |
--------------------------------------------------------------------------------
/test/happyapi.edn:
--------------------------------------------------------------------------------
1 | {:test-provider {:auth_uri "RESOURCE"
2 | :client_id "RESOURCE"
3 | :redirect_uri "http://localhost"}}
4 |
--------------------------------------------------------------------------------
/test/happyapi/apikey/client_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.apikey.client-test
2 | (:require [clojure.edn :as edn]
3 | [clojure.test :refer :all]
4 | [happyapi.apikey.client :as client]
5 | [happyapi.deps :as deps]))
6 |
7 | (deftest make-client-test
8 | (let [config (-> (edn/read-string (slurp "happyapi.edn"))
9 | (:google)
10 | (select-keys [:apikey])
11 | (assoc :provider :google))
12 | kit-request (client/make-client (assoc config :fns (deps/choose [:httpkit :cheshire])))
13 | req {:method :get
14 | :url "https://youtube.googleapis.com/youtube/v3/channels"
15 | :query-params {:part "contentDetails,statistics"
16 | :forUsername "ClojureTV"}}
17 | resp (kit-request req)
18 | [{:strs [kind]}] resp]
19 | (is (= kind "youtube#channel"))))
20 |
--------------------------------------------------------------------------------
/test/happyapi/deps_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.deps-test
2 | (:require [clojure.test :refer [deftest is]]
3 | [com.grzm.uri-template :as uri-template]
4 | [happyapi.deps :as deps]))
5 |
6 | (deftest dependencies-resolved-test
7 | (is (deps/choose [:httpkit :jsonista]))
8 | (is (deps/present))
9 | (is (deps/possible))
10 | (is (thrown? Exception (deps/choose [:bad-key]))))
11 |
12 | (deftest get-url-test
13 | (is (= "BASE/a/B/c"
14 | (uri-template/expand "BASE/a/{b}/c" {:b "B"})))
15 | (is (= "BASE/a/B/c"
16 | (uri-template/expand "BASE/a/{+b}/c" {:b "B"}))))
17 |
--------------------------------------------------------------------------------
/test/happyapi/gen/google/beaver_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.gen.google.beaver-test
2 | (:require [clojure.test :refer [deftest is]]
3 | [happyapi.gen.google.beaver :as beaver]
4 | [happyapi.gen.google.raven :as raven]
5 | [meander.epsilon :as m]))
6 |
7 | (def sheets-api (raven/get-json "https://sheets.googleapis.com/$discovery/rest?version=v4"))
8 |
9 | (defmacro is-match? [x pattern]
10 | `(is (= (m/match ~x ~pattern 'test/match ~'_else ~x) 'test/match)))
11 |
12 | (deftest method-name-test
13 | (is (= 'bar-baz-booz (beaver/method-sym {"id" "foo.bar.baz.booz"}))))
14 |
15 | (deftest extract-method-test
16 | (let [method (beaver/extract-method
17 | [sheets-api {"id" "sheets.spreadsheet.method"
18 | "path" "path"
19 | "parameters" {"spreadsheetId" {"required" true}
20 | "range" {}}
21 | "description" "description"
22 | "scopes" ["scope"]
23 | "httpMethod" "POST"}])]
24 | (is (list? method))
25 | (is-match? method (defn (m/pred symbol? ?fn-name) (m/pred string? ?doc-string) & _))))
26 |
27 | (deftest build-nss-test
28 | (is (seq (beaver/build-api-ns sheets-api))))
29 |
--------------------------------------------------------------------------------
/test/happyapi/gen/google/lion_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.gen.google.lion-test
2 | (:require [clojure.test :refer [deftest is]]
3 | [happyapi.gen.google.lion :as lion]))
4 |
5 | (deftest pprint-str-test
6 | (is (= "[1 2 3]\n" (lion/pprint-str [1 2 3]))))
7 |
8 | (deftest ns-str-test
9 | (is (= "foo\n\n\"a\nb\nc\"\n\nbar\n\nbaz\n" (lion/ns-str '(foo "a\nb\nc" bar baz)))))
10 |
--------------------------------------------------------------------------------
/test/happyapi/gen/google/monkey_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.gen.google.monkey-test
2 | (:require [clojure.test :refer [deftest testing is]]
3 | [happyapi.gen.google.monkey :as monkey]
4 | [happyapi.gen.google.raven :as raven]))
5 |
6 | (deftest fetch-test
7 | (let [api (raven/get-json "https://sheets.googleapis.com/$discovery/rest?version=v4")]
8 | (is (map? api) "should deserialize")
9 | (is (seq api) "should contain definition")))
10 |
11 | (deftest list-apis-test
12 | (let [api-list (monkey/list-apis)]
13 | (is (<= 100 (count api-list) 500)
14 | "should find a good number of Google APIs")))
15 |
--------------------------------------------------------------------------------
/test/happyapi/middleware_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.middleware-test
2 | (:require [clojure.test :refer [deftest is]]
3 | [happyapi.middleware :as middleware]
4 | [happyapi.deps :as deps]))
5 |
6 | (deftest success?-test
7 | (is (middleware/success? {:status 200})))
8 |
9 | (defn raise [ex]
10 | (throw ex))
11 |
12 | (deftest wrap-uri-template-test
13 | (let [request (-> (fn
14 | ([args]
15 | (is (= (:url args) "fooBARbaz")))
16 | ([args respond raise]
17 | (is (= (:url args) "fooBARbaz"))))
18 | (middleware/wrap-uri-template))
19 | args {:uri-template "foo{bar}baz"
20 | :uri-template-args {:bar "BAR"}}]
21 | (request args)
22 | (request args (fn [resp]) raise)))
23 |
24 | (deftest wrap-deitemize-test
25 | (let [request (-> (fn
26 | ([args]
27 | {:status 200
28 | :body {"items" [1 2 3]}})
29 | ([args respond raise]
30 | (respond {:status 200
31 | :body {"items" [4 5 6]}})))
32 | (middleware/wrap-extract-result))]
33 | (is (= (request {}) [1 2 3]))
34 | (request {}
35 | (fn [resp]
36 | (is (= resp [4 5 6])))
37 | raise)))
38 |
39 | (deftest wrap-json-test
40 | (let [request-stub (fn
41 | ([args]
42 | {:status 200
43 | :headers {:content-type "application/json"}
44 | :body "{\"json\": 1, \"edn\": 0}"})
45 | ([args respond raise]
46 | (respond {:status 200
47 | :headers {:content-type "application/json"}
48 | :body "{\"sync\": 1, \"async\": 0}"})))
49 | json (deps/require-dep :cheshire)
50 | request (middleware/wrap-json request-stub {:fns json})
51 | request-keywordized (-> (middleware/wrap-json request-stub {:fns json})
52 | (middleware/wrap-keywordize-keys true))]
53 | (is (= (:body (request {}))
54 | {"json" 1
55 | "edn" 0}))
56 | (is (= (:body (request-keywordized {}))
57 | {:json 1
58 | :edn 0}))
59 | (request {}
60 | (fn [resp]
61 | (is (= (:body resp) {"sync" 1
62 | "async" 0})))
63 | raise)
64 | (request-keywordized {}
65 | (fn [resp]
66 | (is (= (:body resp) {:sync 1
67 | :async 0})))
68 | raise)))
69 |
70 | (deftest wrap-paging-test
71 | (let [responses [[1 2 3]
72 | [4 5 6]
73 | [7 8 9]
74 | [10 11 12]
75 | [13 14 15]]
76 | counter (atom -1)
77 | request (-> (fn
78 | ([args]
79 | (let [c (swap! counter inc)]
80 | {:status 200
81 | :body {"items" (get responses c)
82 | "nextPageToken" (case c
83 | 0 "page2"
84 | 4 "page6"
85 | 5 (throw (ex-info "OH NO" {:id ::oh-no}))
86 | nil)}}))
87 | ([args respond raise]
88 | (let [c (swap! counter inc)]
89 | (respond {:status 200
90 | :body {"items" (get responses c)
91 | "nextPageToken" (case c
92 | 2 "page4"
93 | nil)}}))))
94 | (middleware/wrap-paging)
95 | (middleware/wrap-extract-result))]
96 | (is (= (request {}) [1 2 3 4 5 6]))
97 | (request {}
98 | (fn [resp]
99 | (is (= resp [7 8 9 10 11 12])))
100 | raise)
101 | (is (= {:id :happyapi.middleware/paging-interrupted
102 | :items [13 14 15]}
103 | (try (request {}) (catch Throwable ex (ex-data ex)))))))
104 |
105 | (deftest wrap-informative-exceptions-test
106 | (let [request (-> (deps/choose [:clj-http :cheshire])
107 | (:request)
108 | (middleware/wrap-informative-exceptions))]
109 | (is (thrown? Exception (request {:method :get :url "bad-url.not.a/oh-no"})))
110 | (is (thrown? Exception (request {:method :get :url "https://bad-url.not.au/oh-no"})))))
111 |
--------------------------------------------------------------------------------
/test/happyapi/oauth2/auth_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.oauth2.auth-test
2 | (:require [clojure.edn :as edn]
3 | [clojure.test :refer [deftest is]]
4 | [happyapi.middleware :as middleware]
5 | [happyapi.oauth2.auth :as auth]
6 | [happyapi.oauth2.capture-redirect :as capture-redirect]
7 | [happyapi.oauth2.client :as client]
8 | [happyapi.oauth2.credentials :as credentials]
9 | [happyapi.setup :as setup]))
10 |
11 | ;; To run the tests you need a happyapi.edn file with secrets in it.
12 | ;; This is a particularly annoying test because it revokes, forcing you to login in again.
13 | (deftest refresh-and-revoke-test
14 | (let [provider :google
15 | config (-> (slurp "happyapi.edn") (edn/read-string) (get provider)
16 | (assoc :provider :google)
17 | (client/with-endpoints)
18 | (setup/with-deps))
19 | request (middleware/wrap-json (get-in config [:fns :request]) config)
20 | credentials (credentials/read-credentials provider "user")
21 | scopes ["https://www.googleapis.com/auth/userinfo.email"]
22 | credentials (capture-redirect/update-credentials request config credentials scopes)]
23 | (credentials/save-credentials provider "user" credentials)
24 | (and
25 | (is credentials)
26 | (is (auth/refreshable? config credentials))
27 | (let [credentials (merge (auth/refresh-credentials request config scopes credentials)
28 | credentials)]
29 | (credentials/save-credentials provider "user" credentials)
30 | (and
31 | (is credentials)
32 | (is (middleware/success? (auth/revoke-token request config credentials)))
33 | (credentials/delete-credentials provider "user"))))))
34 |
--------------------------------------------------------------------------------
/test/happyapi/oauth2/capture_redirect_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.oauth2.capture-redirect-test
2 | (:require [clojure.test :refer [deftest is testing]]
3 | [clojure.string :as str]
4 | [happyapi.deps :as deps]
5 | [happyapi.oauth2.capture-redirect :as capture-redirect]
6 | [happyapi.oauth2.auth :as auth]
7 | [clj-http.client :as http]))
8 |
9 | (deftest wait-for-redirect-test
10 | (with-redefs [capture-redirect/browse-to-provider (fn browse-to-provider [{:as config :keys [redirect_uri]} scopes optional]
11 | (http/get (str (str/replace redirect_uri "https" "http")
12 | "?code=CODE&state=" (:state optional))))
13 | auth/exchange-code (fn exhange-code [request config code challenge]
14 | (is (= "CODE" code))
15 | {:access_token "TOKEN"})]
16 | (let [config {:auth_uri "TEST"
17 | :client_id "TEST"
18 | :redirect_uri "http://localhost"
19 | :fns (deps/require-dep :httpkit)}]
20 | (is (= {:access_token "TOKEN"}
21 | (capture-redirect/fresh-credentials http/request
22 | (assoc config :redirect_uri "http://localhost")
23 | [])))
24 | (is (= {:access_token "TOKEN"}
25 | (capture-redirect/fresh-credentials http/request
26 | (assoc config :redirect_uri "http://127.0.0.1")
27 | [])))
28 | (is (= {:access_token "TOKEN"}
29 | (capture-redirect/fresh-credentials http/request
30 | (assoc config :redirect_uri "https://localhost")
31 | [])))
32 | (is (= {:access_token "TOKEN"}
33 | (capture-redirect/fresh-credentials http/request
34 | (assoc config :redirect_uri "http://localhost:8080/redirect")
35 | [])))
36 | (is (thrown? Throwable
37 | (capture-redirect/fresh-credentials http/request
38 | (assoc config :redirect_uri "http://not.localhost")
39 | []))))))
40 |
--------------------------------------------------------------------------------
/test/happyapi/oauth2/client_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.oauth2.client-test
2 | (:require [clojure.edn :as edn]
3 | [clojure.test :refer [deftest is]]
4 | [happyapi.oauth2.client :as client]
5 | [happyapi.deps :as deps]))
6 |
7 | (deftest auth2-test
8 | (let [config (-> (edn/read-string (slurp "happyapi.edn"))
9 | (:google)
10 | (assoc :provider :google))
11 | clj-request (client/make-client (assoc config :fns (deps/choose [:clj-http :jetty :cheshire])))
12 | kit-request (client/make-client (assoc config :fns (deps/choose [:httpkit :cheshire])))
13 | req {:method :get
14 | :url "https://openidconnect.googleapis.com/v1/userinfo"
15 | :scopes ["https://www.googleapis.com/auth/userinfo.email"]
16 | :query-params {}}
17 | resp1 (clj-request req)
18 | _ (is (get-in resp1 ["email"]))
19 | resp2 (kit-request req)
20 | _ (is (get-in resp2 ["email"]))]))
21 |
--------------------------------------------------------------------------------
/test/happyapi/providers/github_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.providers.github-test
2 | (:require [clojure.test :refer :all]
3 | [happyapi.providers.github :as github]))
4 |
5 | (deftest api-request-test
6 | (github/setup! nil)
7 | (is (-> (github/api-request {:method :get
8 | :url "https://api.github.com/user"
9 | :scopes ["user" "user:email"]})
10 | (get "email"))))
11 |
--------------------------------------------------------------------------------
/test/happyapi/providers/google_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.providers.google-test
2 | (:require [clojure.test :refer :all]
3 | [happyapi.providers.google :as google]))
4 |
5 | (deftest api-request-test
6 | (google/setup! nil)
7 | (let [channels (google/api-request {:method :get
8 | :url "https://youtube.googleapis.com/youtube/v3/channels"
9 | :query-params {:part "contentDetails,statistics"
10 | :forUsername "ClojureTV"}
11 | :scopes ["https://www.googleapis.com/auth/youtube.readonly"]})]
12 | (is (seq channels))))
13 |
--------------------------------------------------------------------------------
/test/happyapi/providers/twitter_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.providers.twitter-test
2 | (:require [clojure.test :refer :all]
3 | [happyapi.providers.twitter :as twitter]))
4 |
5 | (deftest api-request-test
6 | (twitter/setup! nil)
7 | (is (-> (twitter/api-request {:method :get
8 | :url "https://api.twitter.com/2/users/me"
9 | :scopes ["tweet.read" "tweet.write" "users.read"]})
10 | (get "username")))
11 | (is (-> (twitter/api-request {:method :delete
12 | :url "https://api.twitter.com/2/tweets/1811986925798195513"
13 | :scopes ["tweet.read" "tweet.write" "users.read"]})
14 | (get "deleted")))
15 | ;; let's not post every time I run the tests...
16 | #_(twitter/api-request {:method :post
17 | :url "https://api.twitter.com/2/tweets"
18 | :scopes ["tweet.read" "tweet.write" "users.read"]
19 | :body {:text "This is a test tweet from HappyAPI"}}))
20 |
--------------------------------------------------------------------------------
/test/happyapi/setup_test.clj:
--------------------------------------------------------------------------------
1 | (ns happyapi.setup-test
2 | (:require [clojure.test :refer [deftest is]]
3 | [clojure.java.io :as io]
4 | [happyapi.setup :as setup])
5 | (:import [java.io File]
6 | [java.util UUID]))
7 |
8 | (def config-file-name "happyapi.edn")
9 | (def config-env-name "HAPPYAPI_CONFIG")
10 |
11 | (def file-config
12 | {:test-provider {:auth_uri "FILE"
13 | :client_id "FILE"
14 | :redirect_uri "http://localhost"}})
15 |
16 | (deftest find-config-test
17 | (let [old-file-name config-file-name
18 | old-file (io/file old-file-name)
19 | old-file? (.exists old-file)
20 | helper-fn (fn helper-fn [expected-auth-uri-value]
21 | (let [config (setup/find-config)]
22 | (is (= expected-auth-uri-value (:auth_uri (:test-provider config))))))
23 | test-fn (fn test-fn []
24 | (when-not (System/getenv config-env-name) ; punt for now if config in env
25 | (helper-fn "RESOURCE")
26 | (try (spit config-file-name (pr-str file-config))
27 | (helper-fn "FILE")
28 | (finally (io/delete-file config-file-name)))))]
29 | ;; We have to check for an existing happyapi.edn since other tests seem to require it.
30 | (if old-file?
31 | ;; save and restore any existing happyapi.edn
32 | (let [parent (.getParentFile old-file)
33 | saved-config-file (File. (str "happyapi" (UUID/randomUUID) ".saved" parent))
34 | saved-config-file-name (.getName saved-config-file)]
35 | (try (io/delete-file saved-config-file-name true) ;renameTo needs a non-existing destination
36 | (.renameTo old-file saved-config-file)
37 | (test-fn)
38 | (finally (io/delete-file old-file true) ;renameTo needs a non-existing destination
39 | (.renameTo saved-config-file old-file))))
40 | (test-fn))))
41 |
--------------------------------------------------------------------------------
/tests.edn:
--------------------------------------------------------------------------------
1 | #kaocha/v1 {}
2 |
--------------------------------------------------------------------------------