diff --git a/.pnp.cjs b/.pnp.cjs index 86a50e1a..56cf9473 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -24,14 +24,19 @@ const RAW_RUNTIME_STATE = {\ "name": "@mittwald/api-client",\ "reference": "workspace:packages/mittwald"\ + },\ + {\ + "name": "@mittwald/api-models",\ + "reference": "workspace:packages/models"\ }\ ],\ "enableTopLevelFallback": true,\ "ignorePatternData": "(^(?:\\\\.yarn\\\\/sdks(?:\\\\/(?!\\\\.{1,2}(?:\\\\/|$))(?:(?:(?!(?:^|\\\\/)\\\\.{1,2}(?:\\\\/|$)).)*?)|$))$)",\ "fallbackExclusionList": [\ - ["@mittwald/api-client", ["workspace:packages/mittwald"]],\ - ["@mittwald/api-client-commons", ["virtual:c868363b9225da9941a57efe275cc186f56bbc5675507a35ee9dd42e682f6dada28f6419bfde0c8606475daefedaf411ac54fad43a935d5d29d658bdc4a86153#workspace:packages/commons", "workspace:packages/commons"]],\ + ["@mittwald/api-client", ["virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#workspace:packages/mittwald", "workspace:packages/mittwald"]],\ + ["@mittwald/api-client-commons", ["virtual:ad873fae75975cc9697a9ce651d7231e567c9fa67c83a3ca4c3e4958bd739741c9c6cd5be2774380be2e036be146f028246a567dbbdab6a226efa33ebb02e832#workspace:packages/commons", "virtual:c868363b9225da9941a57efe275cc186f56bbc5675507a35ee9dd42e682f6dada28f6419bfde0c8606475daefedaf411ac54fad43a935d5d29d658bdc4a86153#workspace:packages/commons", "workspace:packages/commons"]],\ ["@mittwald/api-code-generator", ["workspace:packages/generator"]],\ + ["@mittwald/api-models", ["workspace:packages/models"]],\ ["root", ["workspace:."]]\ ],\ "fallbackPool": [\ @@ -1691,6 +1696,41 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@mittwald/api-client", [\ + ["virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#workspace:packages/mittwald", {\ + "packageLocation": "./.yarn/__virtual__/@mittwald-api-client-virtual-ad873fae75/1/packages/mittwald/",\ + "packageDependencies": [\ + ["@mittwald/api-client", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#workspace:packages/mittwald"],\ + ["@mittwald/api-client-commons", "virtual:ad873fae75975cc9697a9ce651d7231e567c9fa67c83a3ca4c3e4958bd739741c9c6cd5be2774380be2e036be146f028246a567dbbdab6a226efa33ebb02e832#workspace:packages/commons"],\ + ["@mittwald/api-code-generator", "workspace:packages/generator"],\ + ["@mittwald/react-use-promise", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:2.3.12"],\ + ["@types/mittwald__react-use-promise", null],\ + ["@types/node", "npm:20.11.20"],\ + ["@types/react", "npm:18.2.58"],\ + ["@typescript-eslint/eslint-plugin", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:7.0.2"],\ + ["@typescript-eslint/parser", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:7.0.2"],\ + ["browser-or-node", "npm:3.0.0-pre.0"],\ + ["concurrently", "npm:8.2.2"],\ + ["eslint", "npm:8.57.0"],\ + ["eslint-config-prettier", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:9.1.0"],\ + ["eslint-plugin-json", "npm:3.1.0"],\ + ["eslint-plugin-prettier", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:5.1.3"],\ + ["has-flag", "npm:5.0.1"],\ + ["prettier", "npm:3.2.5"],\ + ["prettier-plugin-jsdoc", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:1.3.0"],\ + ["prettier-plugin-pkgsort", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:0.2.1"],\ + ["prettier-plugin-sort-json", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:3.1.0"],\ + ["react", "npm:18.2.0"],\ + ["read-pkg", "npm:9.0.1"],\ + ["rimraf", "npm:5.0.5"],\ + ["tsx", "npm:4.7.1"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"]\ + ],\ + "packagePeers": [\ + "@mittwald/react-use-promise",\ + "@types/mittwald__react-use-promise"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:packages/mittwald", {\ "packageLocation": "./packages/mittwald/",\ "packageDependencies": [\ @@ -1723,6 +1763,42 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@mittwald/api-client-commons", [\ + ["virtual:ad873fae75975cc9697a9ce651d7231e567c9fa67c83a3ca4c3e4958bd739741c9c6cd5be2774380be2e036be146f028246a567dbbdab6a226efa33ebb02e832#workspace:packages/commons", {\ + "packageLocation": "./.yarn/__virtual__/@mittwald-api-client-commons-virtual-e21ed9b49a/1/packages/commons/",\ + "packageDependencies": [\ + ["@mittwald/api-client-commons", "virtual:ad873fae75975cc9697a9ce651d7231e567c9fa67c83a3ca4c3e4958bd739741c9c6cd5be2774380be2e036be146f028246a567dbbdab6a226efa33ebb02e832#workspace:packages/commons"],\ + ["@jest/globals", "npm:29.7.0"],\ + ["@mittwald/react-use-promise", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:2.3.12"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/mittwald__react-use-promise", null],\ + ["@types/parse-path", "npm:7.0.3"],\ + ["@typescript-eslint/eslint-plugin", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:7.0.2"],\ + ["@typescript-eslint/parser", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:7.0.2"],\ + ["axios", "npm:1.6.7"],\ + ["eslint", "npm:8.57.0"],\ + ["eslint-config-prettier", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:9.1.0"],\ + ["eslint-plugin-json", "npm:3.1.0"],\ + ["eslint-plugin-prettier", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:5.1.3"],\ + ["jest", "virtual:b2e857f8c518119e848cf4ef51cff2bf36fb4db0f8e551e1de9a65b88f5466b35ebea1913543d6258bb39baec552d66e8e4c2e8ae0858f2f3f9bf35009befb70#npm:29.7.0"],\ + ["parse-path", "npm:7.0.0"],\ + ["path-to-regexp", "npm:6.2.1"],\ + ["prettier", "npm:3.2.5"],\ + ["prettier-plugin-jsdoc", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:1.3.0"],\ + ["prettier-plugin-pkgsort", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:0.2.1"],\ + ["prettier-plugin-sort-json", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:3.1.0"],\ + ["react", "npm:18.2.0"],\ + ["rimraf", "npm:5.0.5"],\ + ["ts-jest", "virtual:b2e857f8c518119e848cf4ef51cff2bf36fb4db0f8e551e1de9a65b88f5466b35ebea1913543d6258bb39baec552d66e8e4c2e8ae0858f2f3f9bf35009befb70#npm:29.1.2"],\ + ["tsd", "npm:0.30.7"],\ + ["type-fest", "npm:4.10.3"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"]\ + ],\ + "packagePeers": [\ + "@mittwald/react-use-promise",\ + "@types/mittwald__react-use-promise"\ + ],\ + "linkType": "SOFT"\ + }],\ ["virtual:c868363b9225da9941a57efe275cc186f56bbc5675507a35ee9dd42e682f6dada28f6419bfde0c8606475daefedaf411ac54fad43a935d5d29d658bdc4a86153#workspace:packages/commons", {\ "packageLocation": "./.yarn/__virtual__/@mittwald-api-client-commons-virtual-7b98ca03c4/1/packages/commons/",\ "packageDependencies": [\ @@ -1845,6 +1921,35 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@mittwald/api-models", [\ + ["workspace:packages/models", {\ + "packageLocation": "./packages/models/",\ + "packageDependencies": [\ + ["@mittwald/api-models", "workspace:packages/models"],\ + ["@jest/globals", "npm:29.7.0"],\ + ["@mittwald/api-client", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#workspace:packages/mittwald"],\ + ["@mittwald/react-use-promise", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:2.3.12"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/react", "npm:18.2.60"],\ + ["@typescript-eslint/eslint-plugin", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:6.21.0"],\ + ["@typescript-eslint/parser", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:6.21.0"],\ + ["another-deep-freeze", "npm:1.0.0"],\ + ["eslint", "npm:8.57.0"],\ + ["eslint-config-prettier", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:9.1.0"],\ + ["eslint-plugin-json", "npm:3.1.0"],\ + ["eslint-plugin-prettier", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:5.1.3"],\ + ["jest", "virtual:b2e857f8c518119e848cf4ef51cff2bf36fb4db0f8e551e1de9a65b88f5466b35ebea1913543d6258bb39baec552d66e8e4c2e8ae0858f2f3f9bf35009befb70#npm:29.7.0"],\ + ["polytype", "npm:0.17.0"],\ + ["prettier", "npm:3.2.5"],\ + ["react", "npm:18.2.0"],\ + ["rimraf", "npm:5.0.5"],\ + ["ts-jest", "virtual:b2e857f8c518119e848cf4ef51cff2bf36fb4db0f8e551e1de9a65b88f5466b35ebea1913543d6258bb39baec552d66e8e4c2e8ae0858f2f3f9bf35009befb70#npm:29.1.2"],\ + ["type-fest", "npm:4.10.3"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@mittwald/react-use-promise", [\ ["npm:2.3.12", {\ "packageLocation": "./.yarn/cache/@mittwald-react-use-promise-npm-2.3.12-e975d4d801-26f9df552c.zip/node_modules/@mittwald/react-use-promise/",\ @@ -1896,6 +2001,28 @@ const RAW_RUNTIME_STATE = "react"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:2.3.12", {\ + "packageLocation": "./.yarn/__virtual__/@mittwald-react-use-promise-virtual-4b5a1228d8/0/cache/@mittwald-react-use-promise-npm-2.3.12-e975d4d801-26f9df552c.zip/node_modules/@mittwald/react-use-promise/",\ + "packageDependencies": [\ + ["@mittwald/react-use-promise", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:2.3.12"],\ + ["@types/axios", null],\ + ["@types/luxon", "npm:3.4.2"],\ + ["@types/react", "npm:18.2.60"],\ + ["axios", null],\ + ["browser-or-node", "npm:3.0.0-pre.0"],\ + ["luxon", "npm:3.4.4"],\ + ["minimatch", "npm:9.0.3"],\ + ["object-code", "npm:1.3.3"],\ + ["react", "npm:18.2.0"]\ + ],\ + "packagePeers": [\ + "@types/axios",\ + "@types/react",\ + "axios",\ + "react"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@nodelib/fs.scandir", [\ @@ -3206,6 +3333,16 @@ const RAW_RUNTIME_STATE = ["csstype", "npm:3.1.2"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:18.2.60", {\ + "packageLocation": "./.yarn/cache/@types-react-npm-18.2.60-0d7fb7586c-5f2f609162.zip/node_modules/@types/react/",\ + "packageDependencies": [\ + ["@types/react", "npm:18.2.60"],\ + ["@types/prop-types", "npm:15.7.11"],\ + ["@types/scheduler", "npm:0.16.8"],\ + ["csstype", "npm:3.1.2"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@types/responselike", [\ @@ -3319,6 +3456,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@typescript-eslint/eslint-plugin", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/@typescript-eslint-eslint-plugin-npm-6.21.0-eed10a6c66-a57de0f630.zip/node_modules/@typescript-eslint/eslint-plugin/",\ + "packageDependencies": [\ + ["@typescript-eslint/eslint-plugin", "npm:6.21.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["npm:7.0.2", {\ "packageLocation": "./.yarn/cache/@typescript-eslint-eslint-plugin-npm-7.0.2-565c53c25f-430b2f7ca3.zip/node_modules/@typescript-eslint/eslint-plugin/",\ "packageDependencies": [\ @@ -3357,9 +3501,48 @@ const RAW_RUNTIME_STATE = "typescript"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:6.21.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-eslint-plugin-virtual-da874c5bee/0/cache/@typescript-eslint-eslint-plugin-npm-6.21.0-eed10a6c66-a57de0f630.zip/node_modules/@typescript-eslint/eslint-plugin/",\ + "packageDependencies": [\ + ["@typescript-eslint/eslint-plugin", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:6.21.0"],\ + ["@eslint-community/regexpp", "npm:4.10.0"],\ + ["@types/eslint", null],\ + ["@types/typescript", null],\ + ["@types/typescript-eslint__parser", null],\ + ["@typescript-eslint/parser", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:6.21.0"],\ + ["@typescript-eslint/scope-manager", "npm:6.21.0"],\ + ["@typescript-eslint/type-utils", "virtual:da874c5bee40fe0a6770186960f50f4deca85803dca615aa76268a9f4ef99066d2cd928299d57020599e0cae987c2fe725609d4a72ad11f458b9e8208a5ee85a#npm:6.21.0"],\ + ["@typescript-eslint/utils", "virtual:da874c5bee40fe0a6770186960f50f4deca85803dca615aa76268a9f4ef99066d2cd928299d57020599e0cae987c2fe725609d4a72ad11f458b9e8208a5ee85a#npm:6.21.0"],\ + ["@typescript-eslint/visitor-keys", "npm:6.21.0"],\ + ["debug", "virtual:1ff4b5f90832ba0a9c93ba1223af226e44ba70c1126a3740d93562b97bc36544e896a5e95908196f7458713e6a6089a34bfc67362fc6df7fa093bd06c878be47#npm:4.3.4"],\ + ["eslint", "npm:8.57.0"],\ + ["graphemer", "npm:1.4.0"],\ + ["ignore", "npm:5.3.0"],\ + ["natural-compare", "npm:1.4.0"],\ + ["semver", "npm:7.5.4"],\ + ["ts-api-utils", "virtual:c518c15118a103ddf17ada4ae593cb566c82f542fe95fce9e9fad1fb23afc6b60d1711ddc87b531f2d2a1775ad6d7b69af1a607536e253d3f8184a9035ec2698#npm:1.0.3"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"]\ + ],\ + "packagePeers": [\ + "@types/eslint",\ + "@types/typescript-eslint__parser",\ + "@types/typescript",\ + "@typescript-eslint/parser",\ + "eslint",\ + "typescript"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@typescript-eslint/parser", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/@typescript-eslint-parser-npm-6.21.0-d7ff8425ee-4d51cdbc17.zip/node_modules/@typescript-eslint/parser/",\ + "packageDependencies": [\ + ["@typescript-eslint/parser", "npm:6.21.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["npm:7.0.2", {\ "packageLocation": "./.yarn/cache/@typescript-eslint-parser-npm-7.0.2-fda121e4a9-18d6e1bda6.zip/node_modules/@typescript-eslint/parser/",\ "packageDependencies": [\ @@ -3388,9 +3571,40 @@ const RAW_RUNTIME_STATE = "typescript"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:6.21.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-parser-virtual-954d53d5d4/0/cache/@typescript-eslint-parser-npm-6.21.0-d7ff8425ee-4d51cdbc17.zip/node_modules/@typescript-eslint/parser/",\ + "packageDependencies": [\ + ["@typescript-eslint/parser", "virtual:e06e30328889a833dfcbfbc5c33f5e1173628bc1414934ce897e2f1b90b91087fa3f8b59a8a3ced3a53b1f7df474adc821437919c179e8897ac6cecf6bf31f00#npm:6.21.0"],\ + ["@types/eslint", null],\ + ["@types/typescript", null],\ + ["@typescript-eslint/scope-manager", "npm:6.21.0"],\ + ["@typescript-eslint/types", "npm:6.21.0"],\ + ["@typescript-eslint/typescript-estree", "virtual:4ccc9de6b416ca9ee7e582c3eaca4840e74d5860fd7609b3dba4ef5cc926d88b33c8f2ed1e5d257399b09c3b1e1381ae0f2649ff22c26888ea5df3a5591146cf#npm:6.21.0"],\ + ["@typescript-eslint/visitor-keys", "npm:6.21.0"],\ + ["debug", "virtual:1ff4b5f90832ba0a9c93ba1223af226e44ba70c1126a3740d93562b97bc36544e896a5e95908196f7458713e6a6089a34bfc67362fc6df7fa093bd06c878be47#npm:4.3.4"],\ + ["eslint", "npm:8.57.0"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"]\ + ],\ + "packagePeers": [\ + "@types/eslint",\ + "@types/typescript",\ + "eslint",\ + "typescript"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@typescript-eslint/scope-manager", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/@typescript-eslint-scope-manager-npm-6.21.0-60aa61cad2-fe91ac52ca.zip/node_modules/@typescript-eslint/scope-manager/",\ + "packageDependencies": [\ + ["@typescript-eslint/scope-manager", "npm:6.21.0"],\ + ["@typescript-eslint/types", "npm:6.21.0"],\ + ["@typescript-eslint/visitor-keys", "npm:6.21.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:7.0.2", {\ "packageLocation": "./.yarn/cache/@typescript-eslint-scope-manager-npm-7.0.2-5d11ce6ca3-773ea6e61f.zip/node_modules/@typescript-eslint/scope-manager/",\ "packageDependencies": [\ @@ -3402,6 +3616,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@typescript-eslint/type-utils", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/@typescript-eslint-type-utils-npm-6.21.0-b5d74d2e4c-d03fb3ee1c.zip/node_modules/@typescript-eslint/type-utils/",\ + "packageDependencies": [\ + ["@typescript-eslint/type-utils", "npm:6.21.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["npm:7.0.2", {\ "packageLocation": "./.yarn/cache/@typescript-eslint-type-utils-npm-7.0.2-4605eab06a-63bf19c9f5.zip/node_modules/@typescript-eslint/type-utils/",\ "packageDependencies": [\ @@ -3429,9 +3650,37 @@ const RAW_RUNTIME_STATE = "typescript"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:da874c5bee40fe0a6770186960f50f4deca85803dca615aa76268a9f4ef99066d2cd928299d57020599e0cae987c2fe725609d4a72ad11f458b9e8208a5ee85a#npm:6.21.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-type-utils-virtual-4ccc9de6b4/0/cache/@typescript-eslint-type-utils-npm-6.21.0-b5d74d2e4c-d03fb3ee1c.zip/node_modules/@typescript-eslint/type-utils/",\ + "packageDependencies": [\ + ["@typescript-eslint/type-utils", "virtual:da874c5bee40fe0a6770186960f50f4deca85803dca615aa76268a9f4ef99066d2cd928299d57020599e0cae987c2fe725609d4a72ad11f458b9e8208a5ee85a#npm:6.21.0"],\ + ["@types/eslint", null],\ + ["@types/typescript", null],\ + ["@typescript-eslint/typescript-estree", "virtual:4ccc9de6b416ca9ee7e582c3eaca4840e74d5860fd7609b3dba4ef5cc926d88b33c8f2ed1e5d257399b09c3b1e1381ae0f2649ff22c26888ea5df3a5591146cf#npm:6.21.0"],\ + ["@typescript-eslint/utils", "virtual:da874c5bee40fe0a6770186960f50f4deca85803dca615aa76268a9f4ef99066d2cd928299d57020599e0cae987c2fe725609d4a72ad11f458b9e8208a5ee85a#npm:6.21.0"],\ + ["debug", "virtual:1ff4b5f90832ba0a9c93ba1223af226e44ba70c1126a3740d93562b97bc36544e896a5e95908196f7458713e6a6089a34bfc67362fc6df7fa093bd06c878be47#npm:4.3.4"],\ + ["eslint", "npm:8.57.0"],\ + ["ts-api-utils", "virtual:c518c15118a103ddf17ada4ae593cb566c82f542fe95fce9e9fad1fb23afc6b60d1711ddc87b531f2d2a1775ad6d7b69af1a607536e253d3f8184a9035ec2698#npm:1.0.3"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"]\ + ],\ + "packagePeers": [\ + "@types/eslint",\ + "@types/typescript",\ + "eslint",\ + "typescript"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@typescript-eslint/types", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/@typescript-eslint-types-npm-6.21.0-4d08954078-e26da86d6f.zip/node_modules/@typescript-eslint/types/",\ + "packageDependencies": [\ + ["@typescript-eslint/types", "npm:6.21.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:7.0.2", {\ "packageLocation": "./.yarn/cache/@typescript-eslint-types-npm-7.0.2-819fe14ea0-2cba8a0355.zip/node_modules/@typescript-eslint/types/",\ "packageDependencies": [\ @@ -3441,6 +3690,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@typescript-eslint/typescript-estree", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/@typescript-eslint-typescript-estree-npm-6.21.0-04a199adba-b32fa35fca.zip/node_modules/@typescript-eslint/typescript-estree/",\ + "packageDependencies": [\ + ["@typescript-eslint/typescript-estree", "npm:6.21.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["npm:7.0.2", {\ "packageLocation": "./.yarn/cache/@typescript-eslint-typescript-estree-npm-7.0.2-752c678420-307080e29c.zip/node_modules/@typescript-eslint/typescript-estree/",\ "packageDependencies": [\ @@ -3469,6 +3725,48 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ + ["virtual:305168a14b766995bfbec6abff715fba4f55731081c4ffbd44bf22951d065bd64920a022544dd10847e6d94e9058895981fd78ffcfe0b9116d1f5a1f295a7930#npm:6.21.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-typescript-estree-virtual-3cf9c8d6c0/0/cache/@typescript-eslint-typescript-estree-npm-6.21.0-04a199adba-b32fa35fca.zip/node_modules/@typescript-eslint/typescript-estree/",\ + "packageDependencies": [\ + ["@typescript-eslint/typescript-estree", "virtual:305168a14b766995bfbec6abff715fba4f55731081c4ffbd44bf22951d065bd64920a022544dd10847e6d94e9058895981fd78ffcfe0b9116d1f5a1f295a7930#npm:6.21.0"],\ + ["@types/typescript", null],\ + ["@typescript-eslint/types", "npm:6.21.0"],\ + ["@typescript-eslint/visitor-keys", "npm:6.21.0"],\ + ["debug", "virtual:1ff4b5f90832ba0a9c93ba1223af226e44ba70c1126a3740d93562b97bc36544e896a5e95908196f7458713e6a6089a34bfc67362fc6df7fa093bd06c878be47#npm:4.3.4"],\ + ["globby", "npm:11.1.0"],\ + ["is-glob", "npm:4.0.3"],\ + ["minimatch", "npm:9.0.3"],\ + ["semver", "npm:7.5.4"],\ + ["ts-api-utils", "virtual:96f02a4332be199a5f75ed32b9f31aba2daa92afbc5902f58e4aa69c9cfcd445802db05c2253ce0b89c1d03331750be8a0891c813123f4c9b24796b63a86988d#npm:1.0.3"],\ + ["typescript", null]\ + ],\ + "packagePeers": [\ + "@types/typescript",\ + "typescript"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:4ccc9de6b416ca9ee7e582c3eaca4840e74d5860fd7609b3dba4ef5cc926d88b33c8f2ed1e5d257399b09c3b1e1381ae0f2649ff22c26888ea5df3a5591146cf#npm:6.21.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-typescript-estree-virtual-34c52fbc28/0/cache/@typescript-eslint-typescript-estree-npm-6.21.0-04a199adba-b32fa35fca.zip/node_modules/@typescript-eslint/typescript-estree/",\ + "packageDependencies": [\ + ["@typescript-eslint/typescript-estree", "virtual:4ccc9de6b416ca9ee7e582c3eaca4840e74d5860fd7609b3dba4ef5cc926d88b33c8f2ed1e5d257399b09c3b1e1381ae0f2649ff22c26888ea5df3a5591146cf#npm:6.21.0"],\ + ["@types/typescript", null],\ + ["@typescript-eslint/types", "npm:6.21.0"],\ + ["@typescript-eslint/visitor-keys", "npm:6.21.0"],\ + ["debug", "virtual:1ff4b5f90832ba0a9c93ba1223af226e44ba70c1126a3740d93562b97bc36544e896a5e95908196f7458713e6a6089a34bfc67362fc6df7fa093bd06c878be47#npm:4.3.4"],\ + ["globby", "npm:11.1.0"],\ + ["is-glob", "npm:4.0.3"],\ + ["minimatch", "npm:9.0.3"],\ + ["semver", "npm:7.5.4"],\ + ["ts-api-utils", "virtual:c518c15118a103ddf17ada4ae593cb566c82f542fe95fce9e9fad1fb23afc6b60d1711ddc87b531f2d2a1775ad6d7b69af1a607536e253d3f8184a9035ec2698#npm:1.0.3"],\ + ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"]\ + ],\ + "packagePeers": [\ + "@types/typescript",\ + "typescript"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:b2df8af9cf065121112e671e5e44f5805ce554dc67fd100393d155c2cbf14d9f43d6117b55858cf138d7e4ed2d46d5f1cd4387280f4ece2688595b6943dd23f8#npm:7.0.2", {\ "packageLocation": "./.yarn/__virtual__/@typescript-eslint-typescript-estree-virtual-96f02a4332/0/cache/@typescript-eslint-typescript-estree-npm-7.0.2-752c678420-307080e29c.zip/node_modules/@typescript-eslint/typescript-estree/",\ "packageDependencies": [\ @@ -3492,6 +3790,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@typescript-eslint/utils", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/@typescript-eslint-utils-npm-6.21.0-b19969b8aa-b404a2c55a.zip/node_modules/@typescript-eslint/utils/",\ + "packageDependencies": [\ + ["@typescript-eslint/utils", "npm:6.21.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["npm:7.0.2", {\ "packageLocation": "./.yarn/cache/@typescript-eslint-utils-npm-7.0.2-661e21025f-e68bac7774.zip/node_modules/@typescript-eslint/utils/",\ "packageDependencies": [\ @@ -3518,9 +3823,38 @@ const RAW_RUNTIME_STATE = "eslint"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:da874c5bee40fe0a6770186960f50f4deca85803dca615aa76268a9f4ef99066d2cd928299d57020599e0cae987c2fe725609d4a72ad11f458b9e8208a5ee85a#npm:6.21.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-utils-virtual-305168a14b/0/cache/@typescript-eslint-utils-npm-6.21.0-b19969b8aa-b404a2c55a.zip/node_modules/@typescript-eslint/utils/",\ + "packageDependencies": [\ + ["@typescript-eslint/utils", "virtual:da874c5bee40fe0a6770186960f50f4deca85803dca615aa76268a9f4ef99066d2cd928299d57020599e0cae987c2fe725609d4a72ad11f458b9e8208a5ee85a#npm:6.21.0"],\ + ["@eslint-community/eslint-utils", "virtual:4286e12a3a0f74af013bc8f16c6d8fdde823cfbf6389660266b171e551f576c805b0a7a8eb2a7087a5cee7dfe6ebb6e1ea3808d93daf915edc95656907a381bb#npm:4.4.0"],\ + ["@types/eslint", null],\ + ["@types/json-schema", "npm:7.0.15"],\ + ["@types/semver", "npm:7.5.6"],\ + ["@typescript-eslint/scope-manager", "npm:6.21.0"],\ + ["@typescript-eslint/types", "npm:6.21.0"],\ + ["@typescript-eslint/typescript-estree", "virtual:305168a14b766995bfbec6abff715fba4f55731081c4ffbd44bf22951d065bd64920a022544dd10847e6d94e9058895981fd78ffcfe0b9116d1f5a1f295a7930#npm:6.21.0"],\ + ["eslint", "npm:8.57.0"],\ + ["semver", "npm:7.5.4"]\ + ],\ + "packagePeers": [\ + "@types/eslint",\ + "eslint"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@typescript-eslint/visitor-keys", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/@typescript-eslint-visitor-keys-npm-6.21.0-b36d99336e-30422cdc1e.zip/node_modules/@typescript-eslint/visitor-keys/",\ + "packageDependencies": [\ + ["@typescript-eslint/visitor-keys", "npm:6.21.0"],\ + ["@typescript-eslint/types", "npm:6.21.0"],\ + ["eslint-visitor-keys", "npm:3.4.3"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:7.0.2", {\ "packageLocation": "./.yarn/cache/@typescript-eslint-visitor-keys-npm-7.0.2-ff86a4b1c8-da6c1b0729.zip/node_modules/@typescript-eslint/visitor-keys/",\ "packageDependencies": [\ @@ -3857,6 +4191,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["another-deep-freeze", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/another-deep-freeze-npm-1.0.0-5660728ad1-73dd20db61.zip/node_modules/another-deep-freeze/",\ + "packageDependencies": [\ + ["another-deep-freeze", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["ansi-colors", [\ ["npm:4.1.3", {\ "packageLocation": "./.yarn/cache/ansi-colors-npm-4.1.3-8ffd0ae6c7-43d6e2fc7b.zip/node_modules/ansi-colors/",\ @@ -10988,6 +11331,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["polytype", [\ + ["npm:0.17.0", {\ + "packageLocation": "./.yarn/cache/polytype-npm-0.17.0-3439d25a13-f6ebd1752a.zip/node_modules/polytype/",\ + "packageDependencies": [\ + ["polytype", "npm:0.17.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["postcss-selector-parser", [\ ["npm:6.0.13", {\ "packageLocation": "./.yarn/cache/postcss-selector-parser-npm-6.0.13-f732d92326-e779aa1f8c.zip/node_modules/postcss-selector-parser/",\ diff --git a/packages/commons/README.md b/packages/commons/README.md index 977e5bfa..afe081fe 100644 --- a/packages/commons/README.md +++ b/packages/commons/README.md @@ -2,11 +2,12 @@ Common code base used by `@mittwald/api-client-*` package. ## API -### assertStatus +### assertStatus, assertOneOfStatus The API client does not validate any response status by design, to give you the most flexibility while handling also erroneous responses. If you want to assert -some desired response status, you can use the `assertStatus` function. +some desired response status, you can use the `assertStatus` resp. +`assertOneOfStatus` function. #### assertStatus(response, expectedStatus) @@ -31,3 +32,27 @@ const project = response.data; // Project properties can now be accessed safely const name = project.name; ``` + +#### assertOnOfStatus(response, expectedStatus) + +Returns: void + +This method throws an `ApiClientError` if the given `response` does not match +the `expectedStatus`. + +When you are using TypeScript this function also asserts the correct response +type. + +```ts +const response = await client.project.getProject({ + pathParameters: { + projectId: "...", + }, +}); + +assertOneOfStatus(response, [200, 404]); + +if (!response.data) { + console.log("Project not found"); +} +``` diff --git a/packages/commons/src/types/assertOneOfStatus.ts b/packages/commons/src/types/assertOneOfStatus.ts new file mode 100644 index 00000000..cc02aa00 --- /dev/null +++ b/packages/commons/src/types/assertOneOfStatus.ts @@ -0,0 +1,16 @@ +import ApiClientError from "../core/ApiClientError.js"; +import { Response } from "./Response.js"; + +export function assertOneOfStatus( + response: T, + expectedStatus: S[], +): asserts response is T & { status: S } { + if (!expectedStatus.includes(response.status as S)) { + throw ApiClientError.fromResponse( + `Unexpected response status (expected ${expectedStatus}, got: ${response.status})`, + response, + ); + } +} + +export default assertOneOfStatus; diff --git a/packages/commons/src/types/assertStatus.ts b/packages/commons/src/types/assertStatus.ts index 55cb31a6..05016642 100644 --- a/packages/commons/src/types/assertStatus.ts +++ b/packages/commons/src/types/assertStatus.ts @@ -1,16 +1,11 @@ import { Response } from "./Response.js"; -import ApiClientError from "../core/ApiClientError.js"; +import assertOneOfStatus from "./assertOneOfStatus.js"; export function assertStatus( response: T, expectedStatus: S, ): asserts response is T & { status: S } { - if (response.status !== expectedStatus) { - throw ApiClientError.fromResponse( - `Unexpected response status (expected ${expectedStatus}, got: ${response.status})`, - response, - ); - } + assertOneOfStatus(response, [expectedStatus]); } export default assertStatus; diff --git a/packages/commons/src/types/index.ts b/packages/commons/src/types/index.ts index f732100a..1c4c0db7 100644 --- a/packages/commons/src/types/index.ts +++ b/packages/commons/src/types/index.ts @@ -5,3 +5,4 @@ export * from "./OpenAPIOperation.js"; export * from "./http.js"; export * from "./simplify.js"; export * from "./assertStatus.js"; +export * from "./assertOneOfStatus.js"; diff --git a/packages/mittwald/src/index.ts b/packages/mittwald/src/index.ts index e83f7c47..af17f616 100644 --- a/packages/mittwald/src/index.ts +++ b/packages/mittwald/src/index.ts @@ -1,3 +1,3 @@ -export { assertStatus } from "@mittwald/api-client-commons"; +export { assertStatus, assertOneOfStatus } from "@mittwald/api-client-commons"; export { MittwaldAPIClient as MittwaldAPIV2Client } from "./v2/default.js"; export type { MittwaldAPIV2 } from "./generated/v2/types.js"; diff --git a/packages/models/.eslintignore b/packages/models/.eslintignore new file mode 100644 index 00000000..30b95072 --- /dev/null +++ b/packages/models/.eslintignore @@ -0,0 +1,2 @@ +dist/ + diff --git a/packages/models/.eslintrc.yml b/packages/models/.eslintrc.yml new file mode 100644 index 00000000..9496544d --- /dev/null +++ b/packages/models/.eslintrc.yml @@ -0,0 +1,2 @@ +extends: + - "../../config/.eslintrc.yml" diff --git a/packages/models/.prettierignore b/packages/models/.prettierignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/packages/models/.prettierignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/models/.prettierrc.js b/packages/models/.prettierrc.js new file mode 100644 index 00000000..d289ee8d --- /dev/null +++ b/packages/models/.prettierrc.js @@ -0,0 +1,3 @@ +import config from "../../config/.prettierrc.js"; + +export default config; diff --git a/packages/models/README.md b/packages/models/README.md new file mode 100644 index 00000000..a510a4a7 --- /dev/null +++ b/packages/models/README.md @@ -0,0 +1,397 @@ +# mittwald API models + +This package contains a collection of domain models for coherent interaction +with the mittwald API. + +## License + +Copyright (c) 2023 Mittwald CM Service GmbH & Co. KG and contributors + +This project (and all NPM packages) therein is licensed under the MIT License; +see the [LICENSE](../../LICENSE) file for details. + +## Installing + +You can install this package from the regular NPM registry: + +```shell +yarn add @mittwald/api-models +``` + +## Setup + +You will need to initialize an API client in order to operate with the models +provided by this package. Use the `api` global instance for initialization with +some methods. + +```typescript +import { api } from "@mittwald/api-models"; + +api.setupWithApiToken(process.env.MW_API_TOKEN); +``` + +## Examples + +- A **`Reference`** or `ReferenceModel` represents a certain model just by its + ID. +- A **`DetailedModel`** contains all the data of the resource. + +For a more detailed description refer to the section +[Type of models](#Type-of-models) + +```typescript +// Get a detailed project +const detailedProject = await Project.get("p-vka9t3"); + +// Create a project reference +const projectRef = Project.ofId("p-vka9t3"); + +// Get the detailed project from the reference +const anotherDetailedProject = await projectRef.getDetailed(); + +// Update project description +await detailedProject.updateDescription("My new description!"); + +// This method just needs the ID and a description and +// thus is also available on the reference +await projectRef.updateDescription("My new description!"); + +// Accessing the projects server reference +const server = project.server; + +// List all projects of this server +const serversProjects = await server.listProjects(); + +// List all projects +const allProjects = await Project.list(); + +// Iterate over project List Models +for (const project of serversProjects) { + await project.leave(); +} +``` + +## Usage in React + +This package also provides methods aligned to be used in React components. It +uses +[@mittwald/react-use-promise](https://www.npmjs.com/package/@mittwald/react-use-promise) +to encapsulate all asynchronous functions into AsyncResources. More details +about how to use AsyncResources see the package documentation. + +### Installation + +To use the React client you have to install the additional +`@mittwald/react-use-promise` package: + +```shell +yarn add @mittwald/react-use-promise +``` + +All asynchronous methods provide a `use`-method property. This method uses +[@mittwald/react-use-promise](https://www.npmjs.com/package/@mittwald/react-use-promise) +under the hood to "resolve" the promise in the "React way". + +```typescript +const detailedProject = Project.get.use("p-vka9t3"); + +// Create a project reference +const projectRef = Project.ofId("p-vka9t3"); + +// Get the detailed project from the reference +const anotherDetailedProject = projectRef.getDetailed.use(); + +// Accessing the projects server reference +const server = project.server; + +// List all projects of this server +const serversProjects = server.listProjects.use(); + +// List all projects +const allProjects = Project.list.use(); +``` + +## Immutability and state updates + +Most of all models provided by this package represent an associated counter-part +in the backend. When a model is loaded from the backend, the current state is +incorporated into the model instance. To keep it simple and predictable this +**state is immutable and does not change under any circumstances**. As a result +you must create a new instance to get an updated model and propagate it +throughout the runtime code. + +This also applies for operations initiated at client-side. For example when the +`updateDescription` method is called on a project, the project instance will +still have the old description. + +"Watching for changes" is not scope of this package and will be implemented in +future releases or other packagesℒ️. + +## Contribute + +**As a general advice when contributing, be sure to look at the existing source +code and use it as a template!** + +Please consider the following conventions when adding or modifying models. + +### File structure + +Structure the models in meaningful directories. + +### Use the base classes + +Models should extend (or inherit) the correct base class. You can find the base +classes in `src/base`. The following classes are available. + +#### `DataModel` + +The DataModel is the foundation of all model classes that contain a set of +immutable generic data. + +#### `ReferenceModel` + +A ReferenceModel represents a certain model just by its ID. As the most basic +model operations often just need the ID and some input data (deleting, renaming, +...), Reference Models can avoid unnecessary API round trips. + +### Stick to the ubiquitous language + +When adding models or methods pay close attention to the (maybe existing) +language used in the respective domain. Talk to the responsible team if you are +uncertain. + +### Models as abstraction layer + +Models should cover the following aspects: + +- Coherent representation of the business logic +- Simple loading of models and their linked entities +- Methods to interact with the model in an intuitive way +- Preprocessing of the models raw data to increase DX +- Consistent API + +### Type of models + +#### Detailed vs. List Models + +The response type for some models often differs when loading single items or +lists. To reduce the amount of data, the list response type is usually a subset +of the comprehensive model. Add separate classes for the Detailed Model (name it +`[Model]Detailed`) and the List Model (name it `[Model]ListItem`). + +If both model share a common code base, you should add a Common Model (name it +`[Model]Common`). + +#### Reference Models + +A Reference Model represents a certain model just by its ID. As the most basic +model operations often just need the ID and some input data (deleting, renaming, +...), Reference Models can avoid unnecessary API round trips. These classes +should be used as a return type for newly created models or for linked models. + +To get the actual Detailed Model, Reference Models _must_ have a +`function getDetailed(): Promise` method. + +Consider extending the Reference Model when implementing the Entry-Point Model. + +#### Implementation details + +When implementing shared functionality, like in the Common Models, you can use +the [`polytype`](https://www.npmjs.com/package/polytype) library to realize +dynamic multiple inheritance. Be sure to look at the existing source code for +implementation examples. + +#### Entry-Point Model + +Provide a single model (name it `[Model]`) as an entry point for all different +model types (detailed, list, ...). As a convention provide a default export for +this model. + +### Use the correct verbs + +#### `find` + +Entry-Point models should have a static `find` method. The find method returns +the detailed model or may return `undefined` if the model can not be found. + +#### `get` + +In addition to the `find` method Entry-Point models should have a static `get` +method. The get method should return the desired object or throw an +`ObjectNotFoundError`. You can use the `find` method and assert the existence +with the `assertObjectFound` function. + +#### `list` + +When a list of objects should be loaded use a `list` method. It may support a +`query` parameter to filter the result by given criteria. + +#### `create` + +When a model should be created use a static `create` method. This method should +return a reference of the created resource. + +### Accessing "linked" models + +Most of the models are part of a larger model tree. Models should provide +methods to get the parent and child models, like `project.getServer()`, +`server.listProjects()` or `server.getCustomer()`. Use `get`, `list` or `find` +prefixes as described above. + +#### Use Reference Models resp. Entry-Point Models when possible! + +If a linked model provides a Reference Model or Entry-point Model, create it in +the model constructor, to avoid unnecessary API round trips. + +### Abstraction of model behaviors + +Models are usually backed by a set of behaviors, defining the basic model +interactions. In order to actually "use" the model, it must be initialized with +a concrete behavior implementation. This layer of abstraction removes +implementation specific code from the model, and also makes behaviors +exchangeable without any impact on the model itself - for example inside unit +tests. + +Consider using behaviors for: + +- API interactions +- Storing and loading local data +- Complex computations or logic (maybe supported by an external library) + +#### Encapsulate API interaction inside behaviors + +Encapsulate any API interaction inside the model behaviors to prevent strong +coupling of model and API-specific implementation. + +##### Don't πŸ₯΄ + +```typescript +class ProjectDetailed { + public static async find( + id: string, + ): Promise { + const response = await client.project.getProject({ + id, + }); + + if (response.status === 200) { + return new Project(response.data.id, response.data); + } + assertStatus(response, 403); + } +} +``` + +##### Do πŸ˜ƒ + +```typescript +class ProjectDetailed { + public static async find(id: string): Promise { + const data = await config.project.behaviors.find(id); + if (data !== undefined) { + return new Project(data.id, data); + } + } +} +``` + +#### How-to implement behaviors + +Place a `behaviors` folder inside the model that should look like this: + +``` +Project/ +β”œβ”€ behaviors/ +β”‚ β”œβ”€ index.ts +β”‚ β”œβ”€ types.ts (behavior interface) +β”‚ β”œβ”€ api.ts (behavior implementation) +β”‚ β”œβ”€ inmem.ts (behavior implementation) + +``` + +##### Define `types.ts` first + +It is a good starting point to first implement the interface for the behavior. +The interface usually just defines methods used in the behavior. Like + +```ts +export interface ProjectBehaviors { + find: (id: string) => Promise; + updateDescription: (projectId: string, description: string) => Promise; +} +``` + +Then register the behavior in the global behavior configuration +`packages/models/src/config/config.ts`. + +##### Use the behaviors in the model + +If the behavior interface is defined, you can start implementing the model. You +can also first implement the concrete API behavior, to "proof" the behavior is +"working" with the real API. + +```ts +import { config } from "../../config/config.js"; + +class ProjectDetailed { + public static async find(id: string): Promise { + const data = await config.project.behaviors.find(id); + if (data !== undefined) { + return new Project(data.id, data); + } + } +} +``` + +##### Implement the API behavior + +The API behavior depends on an API client. You can implement the behavior as an +object factory, or a simple class implementing the interface. When using the +object factory, you do not have to redeclare the method parameter types. + +Do the implementation specific stuff, thus preparing and executing the request, +and finally processing the response. + +```ts +import { ProjectBehaviors } from "./types.js"; +import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; + +export const apiProjectBehaviors = ( + client: MittwaldAPIV2Client, +): ProjectBehaviors => ({ + find: async (id) => { + const response = await client.project.getProject({ + projectId: id, + }); + + if (response.status === 200) { + return response.data; + } + assertStatus(response, 403); + }, +}); +``` + +### Prepare for React + +All asynchronous methods should provide a `use`-method property. This method +uses +[@mittwald/react-use-promise](https://www.npmjs.com/package/@mittwald/react-use-promise) +under the hood to "resolve" the promise in the "React way". + +To provide this feature to your _async_ model methods, wrap the actual method +with the `provideReact` enhancer. + +```ts +class ProjectDetailed { + public static find = provideReact( + async (id: string): Promise => { + const data = await config.behaviors.project.find(id); + + if (data !== undefined) { + return new ProjectDetailed(data); + } + }, + ); +} +``` diff --git a/packages/models/jest.config.js b/packages/models/jest.config.js new file mode 100644 index 00000000..82b56570 --- /dev/null +++ b/packages/models/jest.config.js @@ -0,0 +1,15 @@ +export default { + roots: ["src"], + preset: "ts-jest/presets/default-esm", + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, +}; diff --git a/packages/models/package.json b/packages/models/package.json new file mode 100644 index 00000000..79bc6aa7 --- /dev/null +++ b/packages/models/package.json @@ -0,0 +1,73 @@ +{ + "name": "@mittwald/api-models", + "version": "0.1.0-alpha.1", + "author": "Mittwald CM Service GmbH & Co. KG ", + "type": "module", + "description": "Collection of domain models for coherent interaction with the API", + "keywords": [ + "api", + "client", + "mittwald", + "rest", + "sdk" + ], + "homepage": "https://developer.mittwald.de", + "repository": "github:mittwald/api-client-js", + "bugs": { + "url": "https://github.com/mittwald/api-client-js/issues" + }, + "license": "MIT", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./react": { + "types": "./dist/types/react.d.ts", + "import": "./dist/esm/react.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "run build:clean && run tsc", + "build:clean": "rimraf dist", + "test": "node --experimental-vm-modules $(yarn bin jest)" + }, + "dependencies": { + "@mittwald/api-client": "workspace:^", + "another-deep-freeze": "^1.0.0", + "polytype": "^0.17.0", + "type-fest": "^4.10.3" + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "@mittwald/react-use-promise": "^2.3.12", + "@types/jest": "^29.5.12", + "@types/react": "^18.2.57", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-json": "^3.1.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "^29.7.0", + "prettier": "^3.2.5", + "react": "^18.2.0", + "rimraf": "^5.0.5", + "ts-jest": "^29.1.2", + "typescript": "^5.3.3" + }, + "peerDependencies": { + "@mittwald/react-use-promise": "^2.3.12" + }, + "peerDependenciesMeta": { + "@mittwald/react-use-promise": { + "optional": true + }, + "react": { + "optional": true + } + } +} diff --git a/packages/models/src/app/AppInstallation/AppInstallation.ts b/packages/models/src/app/AppInstallation/AppInstallation.ts new file mode 100644 index 00000000..42c3078a --- /dev/null +++ b/packages/models/src/app/AppInstallation/AppInstallation.ts @@ -0,0 +1,80 @@ +import { config } from "../../config/config.js"; +import { classes } from "polytype"; +import { DataModel } from "../../base/DataModel.js"; +import assertObjectFound from "../../base/assertObjectFound.js"; +import { ReferenceModel } from "../../base/ReferenceModel.js"; +import type { AsyncResourceVariant } from "../../lib/provideReact.js"; +import { provideReact } from "../../lib/provideReact.js"; +import { + AppInstallationListItemData, + AppInstallationData, + AppInstallationListQuery, +} from "./types.js"; + +export class AppInstallation extends ReferenceModel { + public static find = provideReact( + async (id: string): Promise => { + const data = await config.behaviors.appInstallation.find(id); + if (data !== undefined) { + return new AppInstallationDetailed(data); + } + }, + ); + + public static get = provideReact( + async (id: string): Promise => { + const appInstallation = await this.find(id); + assertObjectFound(appInstallation, this, id); + return appInstallation; + }, + ); + + public static ofId(id: string): AppInstallation { + return new AppInstallation(id); + } + + public static list = provideReact( + async ( + projectId: string, + query: AppInstallationListQuery = {}, + ): Promise> => { + const data = await config.behaviors.appInstallation.list( + projectId, + query, + ); + return data.map((d) => new AppInstallationListItem(d)); + }, + ); + + public getDetailed = provideReact(() => + AppInstallation.get(this.id), + ) as AsyncResourceVariant; +} + +// Common class for future extension +class AppInstallationCommon extends classes( + DataModel, + AppInstallation, +) { + public constructor(data: AppInstallationListItemData | AppInstallationData) { + super([data], [data.id]); + } +} + +export class AppInstallationDetailed extends classes( + AppInstallationCommon, + DataModel, +) { + public constructor(data: AppInstallationData) { + super([data], [data]); + } +} + +export class AppInstallationListItem extends classes( + AppInstallationCommon, + DataModel, +) { + public constructor(data: AppInstallationListItemData) { + super([data], [data]); + } +} diff --git a/packages/models/src/app/AppInstallation/behaviors/api.ts b/packages/models/src/app/AppInstallation/behaviors/api.ts new file mode 100644 index 00000000..bbcd9af1 --- /dev/null +++ b/packages/models/src/app/AppInstallation/behaviors/api.ts @@ -0,0 +1,27 @@ +import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { assertOneOfStatus } from "@mittwald/api-client"; +import { AppInstallationBehaviors } from "./types.js"; + +export const apiAppInstallationBehaviors = ( + client: MittwaldAPIV2Client, +): AppInstallationBehaviors => ({ + find: async (id) => { + const response = await client.app.getAppinstallation({ + appInstallationId: id, + }); + + if (response.status === 200) { + return response.data; + } + assertOneOfStatus(response, [404]); + }, + + list: async (projectId, query) => { + const response = await client.app.listAppinstallations({ + queryParameters: query, + projectId, + }); + assertStatus(response, 200); + return response.data; + }, +}); diff --git a/packages/models/src/app/AppInstallation/behaviors/index.ts b/packages/models/src/app/AppInstallation/behaviors/index.ts new file mode 100644 index 00000000..a7b74f7c --- /dev/null +++ b/packages/models/src/app/AppInstallation/behaviors/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./types.js"; diff --git a/packages/models/src/app/AppInstallation/behaviors/types.ts b/packages/models/src/app/AppInstallation/behaviors/types.ts new file mode 100644 index 00000000..36792072 --- /dev/null +++ b/packages/models/src/app/AppInstallation/behaviors/types.ts @@ -0,0 +1,13 @@ +import { + AppInstallationListItemData, + AppInstallationData, + AppInstallationListQuery, +} from "../types.js"; + +export interface AppInstallationBehaviors { + find: (id: string) => Promise; + list: ( + projectId: string, + query?: AppInstallationListQuery, + ) => Promise; +} diff --git a/packages/models/src/app/AppInstallation/index.ts b/packages/models/src/app/AppInstallation/index.ts new file mode 100644 index 00000000..f820e343 --- /dev/null +++ b/packages/models/src/app/AppInstallation/index.ts @@ -0,0 +1,2 @@ +export * from "./AppInstallation.js"; +export * from "./types.js"; diff --git a/packages/models/src/app/AppInstallation/types.ts b/packages/models/src/app/AppInstallation/types.ts new file mode 100644 index 00000000..f1267102 --- /dev/null +++ b/packages/models/src/app/AppInstallation/types.ts @@ -0,0 +1,10 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export type AppInstallationListQuery = + MittwaldAPIV2.Paths.V2ProjectsProjectIdAppInstallations.Get.Parameters.Query; + +export type AppInstallationData = + MittwaldAPIV2.Operations.AppGetAppinstallation.ResponseData; + +export type AppInstallationListItemData = + MittwaldAPIV2.Operations.AppListAppinstallations.ResponseData[number]; diff --git a/packages/models/src/app/index.ts b/packages/models/src/app/index.ts new file mode 100644 index 00000000..2eee1ff2 --- /dev/null +++ b/packages/models/src/app/index.ts @@ -0,0 +1 @@ +export * from "./AppInstallation/index.js"; diff --git a/packages/models/src/base/DataModel.ts b/packages/models/src/base/DataModel.ts new file mode 100644 index 00000000..29ddd7e0 --- /dev/null +++ b/packages/models/src/base/DataModel.ts @@ -0,0 +1,10 @@ +import { ReadonlyDeep } from "type-fest"; +import deepFreeze from "../lib/deepFreeze.js"; + +export class DataModel { + public readonly data: ReadonlyDeep; + + public constructor(data: T) { + this.data = deepFreeze(data); + } +} diff --git a/packages/models/src/base/ReferenceModel.ts b/packages/models/src/base/ReferenceModel.ts new file mode 100644 index 00000000..30f8054b --- /dev/null +++ b/packages/models/src/base/ReferenceModel.ts @@ -0,0 +1,11 @@ +export abstract class ReferenceModel { + public readonly id: string; + + public constructor(id: string) { + this.id = id; + } + + public describe(): string { + return `${this.constructor.name}@${this.id}`; + } +} diff --git a/packages/models/src/base/assertObjectFound.ts b/packages/models/src/base/assertObjectFound.ts new file mode 100644 index 00000000..182e08fd --- /dev/null +++ b/packages/models/src/base/assertObjectFound.ts @@ -0,0 +1,18 @@ +import ObjectNotFoundError from "../errors/ObjectNotFoundError.js"; +import { Constructor } from "type-fest"; +import { ReferenceModel } from "./ReferenceModel.js"; + +export default function assertObjectFound( + obj: T | undefined, + theClass: Constructor, + refIdOrObject: string | ReferenceModel, +): asserts obj is T { + if (obj === undefined) { + const refName = + typeof refIdOrObject === "string" + ? refIdOrObject + : refIdOrObject.toString(); + + throw new ObjectNotFoundError(theClass.name, refName); + } +} diff --git a/packages/models/src/base/index.ts b/packages/models/src/base/index.ts new file mode 100644 index 00000000..a0c4ebf2 --- /dev/null +++ b/packages/models/src/base/index.ts @@ -0,0 +1 @@ +export * from "./types.js"; diff --git a/packages/models/src/base/types.ts b/packages/models/src/base/types.ts new file mode 100644 index 00000000..a62338b2 --- /dev/null +++ b/packages/models/src/base/types.ts @@ -0,0 +1,3 @@ +import { DataModel } from "./DataModel.js"; + +export type DataType = T extends DataModel ? TData : never; diff --git a/packages/models/src/config/behaviors/api.ts b/packages/models/src/config/behaviors/api.ts new file mode 100644 index 00000000..23613d01 --- /dev/null +++ b/packages/models/src/config/behaviors/api.ts @@ -0,0 +1,51 @@ +import { MittwaldAPIV2Client } from "@mittwald/api-client"; +import { config } from "../config.js"; +import { apiProjectBehaviors } from "../../project/Project/behaviors/index.js"; +import { apiServerBehaviors } from "../../server/Server/behaviors/index.js"; +import { apiCustomerBehaviors } from "../../customer/Customer/behaviors/index.js"; +import { apiIngressBehaviors } from "../../domain/Ingress/behaviors/index.js"; +import { apiAppInstallationBehaviors } from "../../app/AppInstallation/behaviors/index.js"; + +class ApiSetupState { + private _client: MittwaldAPIV2Client | undefined; + + public setupWithClient(client: MittwaldAPIV2Client) { + if (this._client !== undefined) { + throw new Error( + "API already setup. If you want to operate on the API client use api.client.", + ); + } + this._client = client; + + config.behaviors.project = apiProjectBehaviors(client); + config.behaviors.server = apiServerBehaviors(client); + config.behaviors.customer = apiCustomerBehaviors(client); + config.behaviors.ingress = apiIngressBehaviors(client); + config.behaviors.appInstallation = apiAppInstallationBehaviors(client); + } + + public setupWithApiToken(apiToken: string) { + return this.setupWithClient(MittwaldAPIV2Client.newWithToken(apiToken)); + } + + public setupUnauthenticated() { + return this.setupWithClient(MittwaldAPIV2Client.newUnauthenticated()); + } + + public get client(): MittwaldAPIV2Client { + if (!this._client) { + throw new Error("Could not get client. Behavior not initialized."); + } + return this._client; + } + + public get defaults(): (typeof this.client)["axios"]["defaults"] { + return this.client.axios.defaults; + } + + public get interceptors(): (typeof this.client)["axios"]["interceptors"] { + return this.client.axios.interceptors; + } +} + +export const api = new ApiSetupState(); diff --git a/packages/models/src/config/behaviors/index.ts b/packages/models/src/config/behaviors/index.ts new file mode 100644 index 00000000..e4fa9cd3 --- /dev/null +++ b/packages/models/src/config/behaviors/index.ts @@ -0,0 +1 @@ +export * from "./api.js"; diff --git a/packages/models/src/config/config.ts b/packages/models/src/config/config.ts new file mode 100644 index 00000000..fbee52b8 --- /dev/null +++ b/packages/models/src/config/config.ts @@ -0,0 +1,25 @@ +import { ProjectBehaviors } from "../project/Project/behaviors/index.js"; +import { ServerBehaviors } from "../server/Server/behaviors/index.js"; +import { CustomerBehaviors } from "../customer/Customer/behaviors/index.js"; +import { IngressBehaviors } from "../domain/Ingress/behaviors/index.js"; +import { AppInstallationBehaviors } from "../app/AppInstallation/behaviors/index.js"; + +interface Config { + behaviors: { + project: ProjectBehaviors; + server: ServerBehaviors; + customer: CustomerBehaviors; + ingress: IngressBehaviors; + appInstallation: AppInstallationBehaviors; + }; +} + +export const config: Config = { + behaviors: { + project: undefined as unknown as ProjectBehaviors, + server: undefined as unknown as ServerBehaviors, + customer: undefined as unknown as CustomerBehaviors, + ingress: undefined as unknown as IngressBehaviors, + appInstallation: undefined as unknown as AppInstallationBehaviors, + }, +}; diff --git a/packages/models/src/config/index.ts b/packages/models/src/config/index.ts new file mode 100644 index 00000000..f121b6bd --- /dev/null +++ b/packages/models/src/config/index.ts @@ -0,0 +1 @@ +export * from "./behaviors/index.js"; diff --git a/packages/models/src/customer/Customer/Customer.ts b/packages/models/src/customer/Customer/Customer.ts new file mode 100644 index 00000000..65fa949a --- /dev/null +++ b/packages/models/src/customer/Customer/Customer.ts @@ -0,0 +1,76 @@ +import { config } from "../../config/config.js"; +import { classes } from "polytype"; +import { DataModel } from "../../base/DataModel.js"; +import assertObjectFound from "../../base/assertObjectFound.js"; +import { ReferenceModel } from "../../base/ReferenceModel.js"; +import type { AsyncResourceVariant } from "../../lib/provideReact.js"; +import { provideReact } from "../../lib/provideReact.js"; +import { + CustomerListItemData, + CustomerData, + CustomerListQuery, +} from "./types.js"; + +export class Customer extends ReferenceModel { + public static ofId(id: string): Customer { + return new Customer(id); + } + + public static find = provideReact( + async (id: string): Promise => { + const data = await config.behaviors.customer.find(id); + if (data !== undefined) { + return new CustomerDetailed(data); + } + }, + ); + + public static list = provideReact( + async ( + query: CustomerListQuery = {}, + ): Promise>> => { + const data = await config.behaviors.customer.list(query); + return Object.freeze(data.map((d) => new CustomerListItem(d))); + }, + ); + + public static get = provideReact( + async (id: string): Promise => { + const customer = await this.find(id); + assertObjectFound(customer, this, id); + return customer; + }, + ); + + public getDetailed = provideReact(() => + Customer.get(this.id), + ) as AsyncResourceVariant; +} + +// Common class for future extension +class CustomerCommon extends classes( + DataModel, + Customer, +) { + public constructor(data: CustomerListItemData | CustomerData) { + super([data], [data.customerId]); + } +} + +export class CustomerDetailed extends classes( + CustomerCommon, + DataModel, +) { + public constructor(data: CustomerData) { + super([data], [data]); + } +} + +export class CustomerListItem extends classes( + CustomerCommon, + DataModel, +) { + public constructor(data: CustomerListItemData) { + super([data], [data]); + } +} diff --git a/packages/models/src/customer/Customer/behaviors/api.ts b/packages/models/src/customer/Customer/behaviors/api.ts new file mode 100644 index 00000000..34cbcf96 --- /dev/null +++ b/packages/models/src/customer/Customer/behaviors/api.ts @@ -0,0 +1,26 @@ +import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { assertOneOfStatus } from "@mittwald/api-client"; +import { CustomerBehaviors } from "./types.js"; + +export const apiCustomerBehaviors = ( + client: MittwaldAPIV2Client, +): CustomerBehaviors => ({ + find: async (id) => { + const response = await client.customer.getCustomer({ + customerId: id, + }); + + if (response.status === 200) { + return response.data; + } + assertOneOfStatus(response, [404]); + }, + + list: async (query) => { + const response = await client.customer.listCustomers({ + queryParameters: query, + }); + assertStatus(response, 200); + return response.data; + }, +}); diff --git a/packages/models/src/customer/Customer/behaviors/index.ts b/packages/models/src/customer/Customer/behaviors/index.ts new file mode 100644 index 00000000..a7b74f7c --- /dev/null +++ b/packages/models/src/customer/Customer/behaviors/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./types.js"; diff --git a/packages/models/src/customer/Customer/behaviors/types.ts b/packages/models/src/customer/Customer/behaviors/types.ts new file mode 100644 index 00000000..6fc82721 --- /dev/null +++ b/packages/models/src/customer/Customer/behaviors/types.ts @@ -0,0 +1,10 @@ +import { + CustomerListItemData, + CustomerData, + CustomerListQuery, +} from "../types.js"; + +export interface CustomerBehaviors { + find: (id: string) => Promise; + list: (query?: CustomerListQuery) => Promise; +} diff --git a/packages/models/src/customer/Customer/index.ts b/packages/models/src/customer/Customer/index.ts new file mode 100644 index 00000000..6a879bea --- /dev/null +++ b/packages/models/src/customer/Customer/index.ts @@ -0,0 +1,2 @@ +export * from "./Customer.js"; +export * from "./types.js"; diff --git a/packages/models/src/customer/Customer/types.ts b/packages/models/src/customer/Customer/types.ts new file mode 100644 index 00000000..07c62e60 --- /dev/null +++ b/packages/models/src/customer/Customer/types.ts @@ -0,0 +1,10 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export type CustomerListQuery = + MittwaldAPIV2.Paths.V2Customers.Get.Parameters.Query; + +export type CustomerData = + MittwaldAPIV2.Operations.CustomerGetCustomer.ResponseData; + +export type CustomerListItemData = + MittwaldAPIV2.Operations.CustomerListCustomers.ResponseData[number]; diff --git a/packages/models/src/customer/index.ts b/packages/models/src/customer/index.ts new file mode 100644 index 00000000..8fbc082e --- /dev/null +++ b/packages/models/src/customer/index.ts @@ -0,0 +1 @@ +export * from "./Customer/index.js"; diff --git a/packages/models/src/domain/Ingress/Ingress.ts b/packages/models/src/domain/Ingress/Ingress.ts new file mode 100644 index 00000000..31a05d5b --- /dev/null +++ b/packages/models/src/domain/Ingress/Ingress.ts @@ -0,0 +1,86 @@ +import { config } from "../../config/config.js"; +import { classes } from "polytype"; +import { DataModel } from "../../base/DataModel.js"; +import assertObjectFound from "../../base/assertObjectFound.js"; +import { ReferenceModel } from "../../base/ReferenceModel.js"; +import type { AsyncResourceVariant } from "../../lib/provideReact.js"; +import { provideReact } from "../../lib/provideReact.js"; +import { IngressListItemData, IngressData, IngressListQuery } from "./types.js"; +import { IngressPath } from "../IngressPath/IngressPath.js"; + +export class Ingress extends ReferenceModel { + public static ofId(id: string): Ingress { + return new Ingress(id); + } + + public static ofHostname(hostname: string): Ingress { + return Ingress.ofId(hostname); + } + + public static list = provideReact( + async (query: IngressListQuery = {}): Promise> => { + const data = await config.behaviors.ingress.list(query); + return data.map((d) => new IngressListItem(d)); + }, + ); + + public static find = provideReact( + async (id: string): Promise => { + const data = await config.behaviors.ingress.find(id); + if (data !== undefined) { + return new IngressDetailed(data); + } + }, + ); + + public static get = provideReact( + async (id: string): Promise => { + const ingress = await this.find(id); + assertObjectFound(ingress, this, id); + return ingress; + }, + ); + + public getDetailed = provideReact(() => + Ingress.get(this.id), + ) as AsyncResourceVariant; +} + +export class IngressCommon extends classes( + DataModel, + Ingress, +) { + public readonly baseUrl: string; + public readonly paths: ReadonlyArray; + public readonly defaultPath: IngressPath; + + public constructor(data: IngressListItemData | IngressData) { + super([data], [data.id]); + this.baseUrl = `https://${data.hostname}`; + this.paths = Object.freeze(data.paths.map((p) => new IngressPath(this, p))); + + const defaultPath = this.paths.find((p) => p.path === "/"); + if (defaultPath === undefined) { + throw new Error(`Ingress ${this.describe()} has no default path.`); + } + this.defaultPath = defaultPath; + } +} + +export class IngressDetailed extends classes( + IngressCommon, + DataModel, +) { + public constructor(data: IngressData) { + super([data], [data]); + } +} + +export class IngressListItem extends classes( + IngressCommon, + DataModel, +) { + public constructor(data: IngressListItemData) { + super([data], [data]); + } +} diff --git a/packages/models/src/domain/Ingress/behaviors/api.ts b/packages/models/src/domain/Ingress/behaviors/api.ts new file mode 100644 index 00000000..3071a20d --- /dev/null +++ b/packages/models/src/domain/Ingress/behaviors/api.ts @@ -0,0 +1,29 @@ +import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { IngressBehaviors } from "./types.js"; +import { assertOneOfStatus } from "@mittwald/api-client"; + +export const apiIngressBehaviors = ( + client: MittwaldAPIV2Client, +): IngressBehaviors => ({ + find: async (id) => { + const response = await client.domain.ingressGetIngress({ + ingressId: id, + }); + + if (response.status === 200) { + return response.data; + } + assertOneOfStatus(response, [404]); + }, + + list: async (query = {}) => { + const { projectId } = query; + const response = await client.domain.ingressListIngresses({ + queryParameters: { + projectId, + }, + }); + assertStatus(response, 200); + return response.data; + }, +}); diff --git a/packages/models/src/domain/Ingress/behaviors/index.ts b/packages/models/src/domain/Ingress/behaviors/index.ts new file mode 100644 index 00000000..a7b74f7c --- /dev/null +++ b/packages/models/src/domain/Ingress/behaviors/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./types.js"; diff --git a/packages/models/src/domain/Ingress/behaviors/types.ts b/packages/models/src/domain/Ingress/behaviors/types.ts new file mode 100644 index 00000000..c7dfaadc --- /dev/null +++ b/packages/models/src/domain/Ingress/behaviors/types.ts @@ -0,0 +1,10 @@ +import { + IngressListItemData, + IngressData, + IngressListQuery, +} from "../types.js"; + +export interface IngressBehaviors { + find: (id: string) => Promise; + list: (query?: IngressListQuery) => Promise; +} diff --git a/packages/models/src/domain/Ingress/index.ts b/packages/models/src/domain/Ingress/index.ts new file mode 100644 index 00000000..7da95c4f --- /dev/null +++ b/packages/models/src/domain/Ingress/index.ts @@ -0,0 +1,2 @@ +export * from "./Ingress.js"; +export * from "./types.js"; diff --git a/packages/models/src/domain/Ingress/types.ts b/packages/models/src/domain/Ingress/types.ts new file mode 100644 index 00000000..adfd4a5c --- /dev/null +++ b/packages/models/src/domain/Ingress/types.ts @@ -0,0 +1,11 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export interface IngressListQuery { + projectId?: string; +} + +export type IngressData = + MittwaldAPIV2.Operations.IngressGetIngress.ResponseData; + +export type IngressListItemData = + MittwaldAPIV2.Operations.IngressListIngresses.ResponseData[number]; diff --git a/packages/models/src/domain/IngressPath/IngressPath.test.ts b/packages/models/src/domain/IngressPath/IngressPath.test.ts new file mode 100644 index 00000000..390e28ef --- /dev/null +++ b/packages/models/src/domain/IngressPath/IngressPath.test.ts @@ -0,0 +1,25 @@ +import { IngressTargetData } from "../IngressTarget/index.js"; +import { IngressPath } from "./IngressPath.js"; +import { IngressData, IngressDetailed } from "../Ingress/index.js"; +import { IngressPathData } from "./types.js"; + +const ingressData: Partial = { + id: "abc", + paths: [], +}; + +const corruptIngressTargetData = {} as IngressTargetData; + +const ingressPathData: Partial = { + path: "/", + target: corruptIngressTargetData, +}; + +test("Creating IngressPath with corrupt IngressTarget throws error", () => { + expect(() => { + new IngressPath( + new IngressDetailed(ingressData as IngressData), + ingressPathData as IngressPathData, + ); + }).toThrowError("Ingress IngressDetailed@abc has no default path."); +}); diff --git a/packages/models/src/domain/IngressPath/IngressPath.ts b/packages/models/src/domain/IngressPath/IngressPath.ts new file mode 100644 index 00000000..69732828 --- /dev/null +++ b/packages/models/src/domain/IngressPath/IngressPath.ts @@ -0,0 +1,22 @@ +import { DataModel } from "../../base/DataModel.js"; +import { IngressPathData } from "./types.js"; +import { IngressCommon, Ingress } from "../Ingress/index.js"; +import { + IngressTarget, + ingressTargetFactory, +} from "../IngressTarget/IngressTarget.js"; + +export class IngressPath extends DataModel { + public readonly ingress: Ingress; + public readonly path: string; + public readonly url: URL; + public readonly target: IngressTarget; + + public constructor(ingress: IngressCommon, data: IngressPathData) { + super(data); + this.ingress = ingress; + this.path = data.path; + this.url = new URL(data.path, ingress.baseUrl); + this.target = ingressTargetFactory(this, data.target); + } +} diff --git a/packages/models/src/domain/IngressPath/index.ts b/packages/models/src/domain/IngressPath/index.ts new file mode 100644 index 00000000..237d751d --- /dev/null +++ b/packages/models/src/domain/IngressPath/index.ts @@ -0,0 +1,2 @@ +export * from "./IngressPath.js"; +export * from "./types.js"; diff --git a/packages/models/src/domain/IngressPath/types.ts b/packages/models/src/domain/IngressPath/types.ts new file mode 100644 index 00000000..3253a422 --- /dev/null +++ b/packages/models/src/domain/IngressPath/types.ts @@ -0,0 +1,3 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export type IngressPathData = MittwaldAPIV2.Components.Schemas.IngressPath; diff --git a/packages/models/src/domain/IngressTarget/IngressTarget.test-types.ts b/packages/models/src/domain/IngressTarget/IngressTarget.test-types.ts new file mode 100644 index 00000000..ba0442d7 --- /dev/null +++ b/packages/models/src/domain/IngressTarget/IngressTarget.test-types.ts @@ -0,0 +1,18 @@ +import { IngressTarget } from "./IngressTarget.js"; + +const target = {} as IngressTarget; + +// @ts-expect-error use type-guard first +void target.url; + +if (target.type === "redirect") { + void target.url; +} + +if (target.type === "directory") { + void target.directory; +} + +if (target.type === "appInstallation") { + void target.appInstallation.id; +} diff --git a/packages/models/src/domain/IngressTarget/IngressTarget.ts b/packages/models/src/domain/IngressTarget/IngressTarget.ts new file mode 100644 index 00000000..93ae9074 --- /dev/null +++ b/packages/models/src/domain/IngressTarget/IngressTarget.ts @@ -0,0 +1,87 @@ +import { DataModel } from "../../base/DataModel.js"; +import { + IngressUndefinedTargetData, + IngressDirectoryTargetData, + IngressAppInstallationTargetData, + IngressRedirectTargetData, + IngressTargetData, +} from "./types.js"; +import { IngressPath } from "../IngressPath/IngressPath.js"; +import { AppInstallation } from "../../app/AppInstallation/index.js"; + +abstract class IngressTargetBase< + T extends IngressTargetData, +> extends DataModel { + public readonly path: IngressPath; + + public constructor(path: IngressPath, data: T) { + super(data); + this.path = path; + } +} + +export class IngressRedirectTarget extends IngressTargetBase { + public readonly type = "redirect"; + public readonly url: URL; + + public constructor(path: IngressPath, data: IngressRedirectTargetData) { + super(path, data); + this.url = new URL(data.url); + } +} + +export class IngressDirectoryTarget extends IngressTargetBase { + public readonly type = "directory"; + public readonly directory: string; + + public constructor(path: IngressPath, data: IngressDirectoryTargetData) { + super(path, data); + this.directory = data.directory; + } +} + +export class IngressAppInstallationTarget extends IngressTargetBase { + public readonly type = "appInstallation"; + public readonly appInstallation: AppInstallation; + + public constructor( + path: IngressPath, + data: IngressAppInstallationTargetData, + ) { + super(path, data); + this.appInstallation = AppInstallation.ofId(data.installationId); + } +} + +export class IngressUndefinedTarget extends IngressTargetBase { + public readonly type = "undefined"; +} + +export type IngressTarget = + | IngressRedirectTarget + | IngressDirectoryTarget + | IngressAppInstallationTarget + | IngressUndefinedTarget; + +export const ingressTargetFactory = ( + path: IngressPath, + data: IngressTargetData, +): IngressTarget => { + if ("directory" in data) { + return new IngressDirectoryTarget(path, data); + } + + if ("url" in data) { + return new IngressRedirectTarget(path, data); + } + + if ("installationId" in data) { + return new IngressAppInstallationTarget(path, data); + } + + if ("useDefaultPage" in data) { + return new IngressUndefinedTarget(path, data); + } + + throw new Error("Ingress target type is not supported."); +}; diff --git a/packages/models/src/domain/IngressTarget/index.ts b/packages/models/src/domain/IngressTarget/index.ts new file mode 100644 index 00000000..9af4f36a --- /dev/null +++ b/packages/models/src/domain/IngressTarget/index.ts @@ -0,0 +1,2 @@ +export * from "./IngressTarget.js"; +export * from "./types.js"; diff --git a/packages/models/src/domain/IngressTarget/types.ts b/packages/models/src/domain/IngressTarget/types.ts new file mode 100644 index 00000000..45115034 --- /dev/null +++ b/packages/models/src/domain/IngressTarget/types.ts @@ -0,0 +1,19 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export type IngressRedirectTargetData = + MittwaldAPIV2.Components.Schemas.IngressTargetUrl; + +export type IngressDirectoryTargetData = + MittwaldAPIV2.Components.Schemas.IngressTargetDirectory; + +export type IngressAppInstallationTargetData = + MittwaldAPIV2.Components.Schemas.IngressTargetInstallation; + +export type IngressUndefinedTargetData = + MittwaldAPIV2.Components.Schemas.IngressTargetUseDefaultPage; + +export type IngressTargetData = + | IngressRedirectTargetData + | IngressDirectoryTargetData + | IngressAppInstallationTargetData + | IngressUndefinedTargetData; diff --git a/packages/models/src/domain/index.ts b/packages/models/src/domain/index.ts new file mode 100644 index 00000000..b92ce4b7 --- /dev/null +++ b/packages/models/src/domain/index.ts @@ -0,0 +1 @@ +export * from "./Ingress/index.js"; diff --git a/packages/models/src/errors/ObjectNotFoundError.ts b/packages/models/src/errors/ObjectNotFoundError.ts new file mode 100644 index 00000000..56b0b344 --- /dev/null +++ b/packages/models/src/errors/ObjectNotFoundError.ts @@ -0,0 +1,7 @@ +export default class ObjectNotFoundError extends Error { + public constructor(type: string, refName: string) { + super(`${type}@${refName} not found`); + this.name = "ObjectNotFoundError"; + Object.setPrototypeOf(this, ObjectNotFoundError.prototype); + } +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts new file mode 100644 index 00000000..2bda4d61 --- /dev/null +++ b/packages/models/src/index.ts @@ -0,0 +1,7 @@ +export { MittwaldAPIV2Client } from "@mittwald/api-client"; +export * from "./config/index.js"; +export * from "./project/index.js"; +export * from "./server/index.js"; +export * from "./domain/index.js"; +export * from "./customer/index.js"; +export * from "./base/index.js"; diff --git a/packages/models/src/lib/deepFreeze.ts b/packages/models/src/lib/deepFreeze.ts new file mode 100644 index 00000000..29956697 --- /dev/null +++ b/packages/models/src/lib/deepFreeze.ts @@ -0,0 +1,9 @@ +import deepFreezeLib from "another-deep-freeze"; +import type { ReadonlyDeep } from "type-fest"; +export type { ReadonlyDeep } from "type-fest"; + +type DeepFreeze = (subject: T) => ReadonlyDeep; + +const deepFreeze = deepFreezeLib.default as DeepFreeze; + +export default deepFreeze; diff --git a/packages/models/src/lib/provideReact.ts b/packages/models/src/lib/provideReact.ts new file mode 100644 index 00000000..8464b6b2 --- /dev/null +++ b/packages/models/src/lib/provideReact.ts @@ -0,0 +1,28 @@ +import { AsyncResource, reactUsePromise } from "../react/reactUsePromise.js"; + +type FnParameters = unknown[]; +type AsyncFn = ( + ...args: TParams +) => Promise; + +export const provideReact = ( + loader: AsyncFn, +) => + Object.assign(loader, { + asResource: (...params: TParams) => + reactUsePromise.getAsyncResource(loader, params), + use: (...params: TParams) => + reactUsePromise.getAsyncResource(loader, params).use(), + }) as AsyncResourceVariant; + +export type AsyncResourceVariant< + TValue, + TParams extends FnParameters, +> = TParams extends null + ? AsyncFn & { + asResource: () => AsyncResource; + } + : AsyncFn & { + asResource: (...params: TParams) => AsyncResource; + use: (...params: TParams) => TValue; + }; diff --git a/packages/models/src/lib/types.ts b/packages/models/src/lib/types.ts new file mode 100644 index 00000000..18cbfad8 --- /dev/null +++ b/packages/models/src/lib/types.ts @@ -0,0 +1,13 @@ +export type ParamsExceptFirst any> = T extends ( + ignoredFirst: any, + ...args: infer P +) => any + ? P + : never; + +export type FirstParameter any> = T extends ( + first: infer P, + ...args: any[] +) => any + ? P + : never; diff --git a/packages/models/src/project/Project/Project.ts b/packages/models/src/project/Project/Project.ts new file mode 100644 index 00000000..01a69bcb --- /dev/null +++ b/packages/models/src/project/Project/Project.ts @@ -0,0 +1,113 @@ +import { ProjectListItemData, ProjectData, ProjectListQuery } from "./types.js"; +import { config } from "../../config/config.js"; +import { classes } from "polytype"; +import { DataModel } from "../../base/DataModel.js"; +import assertObjectFound from "../../base/assertObjectFound.js"; +import { Server } from "../../server/index.js"; +import { + type AsyncResourceVariant, + provideReact, +} from "../../lib/provideReact.js"; +import { Customer } from "../../customer/Customer/Customer.js"; +import { ReferenceModel } from "../../base/ReferenceModel.js"; +import { Ingress, IngressListItem } from "../../domain/index.js"; + +export class Project extends ReferenceModel { + public static ofId(id: string): Project { + return new Project(id); + } + + public static find = provideReact( + async (id: string): Promise => { + const data = await config.behaviors.project.find(id); + + if (data !== undefined) { + return new ProjectDetailed(data); + } + }, + ); + + public static get = provideReact( + async (id: string): Promise => { + const project = await this.find(id); + assertObjectFound(project, this, id); + return project; + }, + ); + + public static list = provideReact( + async ( + query: ProjectListQuery = {}, + ): Promise>> => { + const data = await config.behaviors.project.list(query); + return Object.freeze(data.map((d) => new ProjectListItem(d))); + }, + ); + + public static async create( + serverId: string, + description: string, + ): Promise { + const { id } = await config.behaviors.project.create(serverId, description); + return new Project(id); + } + + public getDetailed = provideReact(() => + Project.get(this.id), + ) as AsyncResourceVariant; + + public listIngresses = provideReact(() => + Ingress.list({ projectId: this.id }), + ) as AsyncResourceVariant; + + public getDefaultIngress = provideReact(async () => { + const ingresses = await Project.ofId(this.id).listIngresses(); + const defaultIngress = ingresses.find((i) => i.data.isDefault); + assertObjectFound(defaultIngress, IngressListItem, this); + return defaultIngress; + }) as AsyncResourceVariant; + + public async updateDescription(description: string): Promise { + await config.behaviors.project.updateDescription(this.id, description); + } + + public async leave(): Promise { + await config.behaviors.project.leave(this.id); + } + + public async delete(): Promise { + await config.behaviors.project.delete(this.id); + } +} + +class ProjectCommon extends classes( + DataModel, + Project, +) { + public readonly server: Server | undefined; + public readonly customer: Customer; + + public constructor(data: ProjectListItemData | ProjectData) { + super([data], [data.id]); + this.server = data.serverId ? Server.ofId(data.serverId) : undefined; + this.customer = Customer.ofId(data.customerId); + } +} + +export class ProjectDetailed extends classes( + ProjectCommon, + DataModel, +) { + public constructor(data: ProjectData) { + super([data], [data]); + } +} + +export class ProjectListItem extends classes( + ProjectCommon, + DataModel, +) { + public constructor(data: ProjectListItemData) { + super([data], [data]); + } +} diff --git a/packages/models/src/project/Project/behaviors/api.ts b/packages/models/src/project/Project/behaviors/api.ts new file mode 100644 index 00000000..5836e999 --- /dev/null +++ b/packages/models/src/project/Project/behaviors/api.ts @@ -0,0 +1,61 @@ +import { ProjectBehaviors } from "./types.js"; +import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { assertOneOfStatus } from "@mittwald/api-client"; + +export const apiProjectBehaviors = ( + client: MittwaldAPIV2Client, +): ProjectBehaviors => ({ + find: async (id) => { + const response = await client.project.getProject({ + projectId: id, + }); + + if (response.status === 200) { + return response.data; + } + assertOneOfStatus(response, [403]); + }, + + list: async (query) => { + const response = await client.project.listProjects({ + queryParameters: query, + }); + assertStatus(response, 200); + return response.data; + }, + + create: async (serverId: string, description: string) => { + const response = await client.project.createProject({ + serverId, + data: { + description, + }, + }); + assertStatus(response, 201); + return response.data; + }, + + leave: async (id: string) => { + const response = await client.project.leaveProject({ + projectId: id, + }); + assertStatus(response, 204); + }, + + delete: async (id: string) => { + const response = await client.project.deleteProject({ + projectId: id, + }); + assertStatus(response, 204); + }, + + updateDescription: async (id: string, description: string) => { + const response = await client.project.updateProjectDescription({ + projectId: id, + data: { + description, + }, + }); + assertStatus(response, 204); + }, +}); diff --git a/packages/models/src/project/Project/behaviors/inMem.ts b/packages/models/src/project/Project/behaviors/inMem.ts new file mode 100644 index 00000000..c2878ab5 --- /dev/null +++ b/packages/models/src/project/Project/behaviors/inMem.ts @@ -0,0 +1,33 @@ +import { ProjectBehaviors } from "./types.js"; +import { ProjectData } from "../types.js"; + +export const inMemProjectBehaviors = ( + store: Map, +): ProjectBehaviors => ({ + find: async (id) => store.get(id), + + list: async () => { + return Array.from(store.values()).map((detailedProject) => ({ + ...detailedProject, + customerMeta: { + id: detailedProject.customerId, + }, + })); + }, + + create: async () => { + throw new Error("Not implemented"); + }, + + leave: async () => { + throw new Error("Not implemented"); + }, + + delete: async () => { + throw new Error("Not implemented"); + }, + + updateDescription: async () => { + throw new Error("Not implemented"); + }, +}); diff --git a/packages/models/src/project/Project/behaviors/index.ts b/packages/models/src/project/Project/behaviors/index.ts new file mode 100644 index 00000000..395a6730 --- /dev/null +++ b/packages/models/src/project/Project/behaviors/index.ts @@ -0,0 +1,3 @@ +export * from "./api.js"; +export * from "./inMem.js"; +export * from "./types.js"; diff --git a/packages/models/src/project/Project/behaviors/types.ts b/packages/models/src/project/Project/behaviors/types.ts new file mode 100644 index 00000000..ee5944a7 --- /dev/null +++ b/packages/models/src/project/Project/behaviors/types.ts @@ -0,0 +1,16 @@ +import { + ProjectListItemData, + ProjectData, + ProjectListQuery, +} from "../types.js"; + +export interface ProjectBehaviors { + find: (id: string) => Promise; + list: (query?: ProjectListQuery) => Promise; + + create: (serverId: string, description: string) => Promise<{ id: string }>; + + leave: (projectId: string) => Promise; + delete: (projectId: string) => Promise; + updateDescription: (projectId: string, description: string) => Promise; +} diff --git a/packages/models/src/project/Project/index.ts b/packages/models/src/project/Project/index.ts new file mode 100644 index 00000000..dc61385c --- /dev/null +++ b/packages/models/src/project/Project/index.ts @@ -0,0 +1,2 @@ +export * from "./Project.js"; +export * from "./types.js"; diff --git a/packages/models/src/project/Project/types.ts b/packages/models/src/project/Project/types.ts new file mode 100644 index 00000000..857101cd --- /dev/null +++ b/packages/models/src/project/Project/types.ts @@ -0,0 +1,10 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export type ProjectListQuery = + MittwaldAPIV2.Paths.V2Projects.Get.Parameters.Query; + +export type ProjectData = + MittwaldAPIV2.Operations.ProjectGetProject.ResponseData; + +export type ProjectListItemData = + MittwaldAPIV2.Operations.ProjectListProjects.ResponseData[number]; diff --git a/packages/models/src/project/index.ts b/packages/models/src/project/index.ts new file mode 100644 index 00000000..12f077c8 --- /dev/null +++ b/packages/models/src/project/index.ts @@ -0,0 +1 @@ +export * from "./Project/index.js"; diff --git a/packages/models/src/react.ts b/packages/models/src/react.ts new file mode 100644 index 00000000..7177a171 --- /dev/null +++ b/packages/models/src/react.ts @@ -0,0 +1 @@ +export * from "./react/index.js"; diff --git a/packages/models/src/react/MittwaldApiModelProvider.ts b/packages/models/src/react/MittwaldApiModelProvider.ts new file mode 100644 index 00000000..77caf285 --- /dev/null +++ b/packages/models/src/react/MittwaldApiModelProvider.ts @@ -0,0 +1,16 @@ +import React, { createElement, FC, PropsWithChildren } from "react"; +import { setModule } from "./reactUsePromise.js"; + +let loadingPromise: Promise | undefined = undefined; + +export const MittwaldApiModelProvider: FC = (props) => { + if (loadingPromise === undefined) { + loadingPromise = import("@mittwald/react-use-promise").then((m) => { + setModule(m); + }); + + throw loadingPromise; + } + + return createElement(React.Fragment, undefined, props.children); +}; diff --git a/packages/models/src/react/index.ts b/packages/models/src/react/index.ts new file mode 100644 index 00000000..ecb1eee8 --- /dev/null +++ b/packages/models/src/react/index.ts @@ -0,0 +1,4 @@ +export * from "./MittwaldApiModelProvider.js"; +export * from "./reactUsePromise.js"; +export { type AsyncResourceVariant } from "../lib/provideReact.js"; +export { provideReact } from "../lib/provideReact.js"; diff --git a/packages/models/src/react/reactUsePromise.ts b/packages/models/src/react/reactUsePromise.ts new file mode 100644 index 00000000..8f49ab16 --- /dev/null +++ b/packages/models/src/react/reactUsePromise.ts @@ -0,0 +1,30 @@ +type ReactUsePromiseModule = typeof import("@mittwald/react-use-promise"); + +export type { + AsyncResource, + getAsyncResource, +} from "@mittwald/react-use-promise"; + +let module: ReactUsePromiseModule | undefined = undefined; + +/** @internal */ +export const reactUsePromise = new Proxy( + {} as ReactUsePromiseModule, + { + get: (_, prop) => { + if (module === undefined) { + // @todo Provide better error message + throw new Error("ModelProvider not found"); + } + + if (prop in module) { + return module[prop as keyof typeof module]; + } + }, + }, +); + +/** @internal */ +export const setModule = (theModule: ReactUsePromiseModule): void => { + module = theModule; +}; diff --git a/packages/models/src/server/Server/Server.ts b/packages/models/src/server/Server/Server.ts new file mode 100644 index 00000000..742d20f1 --- /dev/null +++ b/packages/models/src/server/Server/Server.ts @@ -0,0 +1,77 @@ +import { ReferenceModel } from "../../base/ReferenceModel.js"; +import { ServerListItemData, ServerData, ServerListQuery } from "./types.js"; +import { config } from "../../config/config.js"; +import { classes } from "polytype"; +import { DataModel } from "../../base/DataModel.js"; +import assertObjectFound from "../../base/assertObjectFound.js"; +import { Project } from "../../project/index.js"; +import { FirstParameter, ParamsExceptFirst } from "../../lib/types.js"; +import { AsyncResourceVariant, provideReact } from "../../lib/provideReact.js"; + +export class Server extends ReferenceModel { + public static find = provideReact( + async (id: string): Promise => { + const serverData = await config.behaviors.server.find(id); + + if (serverData !== undefined) { + return new ServerDetailed([serverData]); + } + }, + ); + + public static get = provideReact(async (id: string): Promise => { + const server = await ServerDetailed.find(id); + assertObjectFound(server, this, id); + return server; + }); + + public static list = provideReact( + async (query: ServerListQuery = {}): Promise => { + const projectListData = await config.behaviors.server.list(query); + return projectListData.map((d) => new ServerListItem([d])); + }, + ); + + public static ofId(id: string): Server { + return new Server(id); + } + + public async createProject( + ...parameters: ParamsExceptFirst + ): ReturnType { + return Project.create(this.id, ...parameters); + } + + public listProjects = provideReact( + async ( + query: Omit, "serverId"> = {}, + ): ReturnType => { + return Project.list({ + ...query, + serverId: this.id, + }); + }, + ); + + public getDetailed = provideReact(() => + ServerDetailed.get(this.id), + ) as AsyncResourceVariant; +} + +// Common class for future extension +class ServerCommon extends classes( + DataModel, + Server, +) { + public constructor(data: ServerListItemData | ServerData) { + super([data], [data.id]); + } +} + +export class ServerListItem extends classes< + [typeof ServerCommon, typeof DataModel] +>(ServerCommon, DataModel) {} + +export class ServerDetailed extends classes< + [typeof ServerCommon, typeof DataModel] +>(ServerCommon, DataModel) {} diff --git a/packages/models/src/server/Server/behaviors/api.ts b/packages/models/src/server/Server/behaviors/api.ts new file mode 100644 index 00000000..36fb3434 --- /dev/null +++ b/packages/models/src/server/Server/behaviors/api.ts @@ -0,0 +1,29 @@ +import { + assertStatus, + assertOneOfStatus, + MittwaldAPIV2Client, +} from "@mittwald/api-client"; +import { ServerBehaviors } from "./types.js"; + +export const apiServerBehaviors = ( + client: MittwaldAPIV2Client, +): ServerBehaviors => ({ + find: async (id) => { + const response = await client.project.getServer({ + serverId: id, + }); + + if (response.status === 200) { + return response.data; + } + assertOneOfStatus(response, [403, 404]); + }, + + list: async (query) => { + const response = await client.project.listServers({ + queryParameters: query, + }); + assertStatus(response, 200); + return response.data; + }, +}); diff --git a/packages/models/src/server/Server/behaviors/index.ts b/packages/models/src/server/Server/behaviors/index.ts new file mode 100644 index 00000000..a7b74f7c --- /dev/null +++ b/packages/models/src/server/Server/behaviors/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./types.js"; diff --git a/packages/models/src/server/Server/behaviors/types.ts b/packages/models/src/server/Server/behaviors/types.ts new file mode 100644 index 00000000..1280d722 --- /dev/null +++ b/packages/models/src/server/Server/behaviors/types.ts @@ -0,0 +1,6 @@ +import { ServerListItemData, ServerData, ServerListQuery } from "../types.js"; + +export interface ServerBehaviors { + find: (id: string) => Promise; + list: (query?: ServerListQuery) => Promise; +} diff --git a/packages/models/src/server/Server/index.ts b/packages/models/src/server/Server/index.ts new file mode 100644 index 00000000..cfc090f7 --- /dev/null +++ b/packages/models/src/server/Server/index.ts @@ -0,0 +1 @@ +export * from "./Server.js"; diff --git a/packages/models/src/server/Server/types.ts b/packages/models/src/server/Server/types.ts new file mode 100644 index 00000000..daff5d60 --- /dev/null +++ b/packages/models/src/server/Server/types.ts @@ -0,0 +1,9 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export type ServerListQuery = + MittwaldAPIV2.Paths.V2Servers.Get.Parameters.Query; + +export type ServerData = MittwaldAPIV2.Operations.ProjectGetServer.ResponseData; + +export type ServerListItemData = + MittwaldAPIV2.Operations.ProjectListServers.ResponseData[number]; diff --git a/packages/models/src/server/index.ts b/packages/models/src/server/index.ts new file mode 100644 index 00000000..51c11369 --- /dev/null +++ b/packages/models/src/server/index.ts @@ -0,0 +1 @@ +export * from "./Server/index.js"; diff --git a/packages/models/tsconfig.json b/packages/models/tsconfig.json new file mode 100644 index 00000000..3ebbf20c --- /dev/null +++ b/packages/models/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declarationDir": "./dist/types", + "outDir": "./dist/esm", + "rootDir": "./src" + }, + "exclude": ["test-d/**/*", "*.test.ts"], + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"] +} diff --git a/yarn.lock b/yarn.lock index 7aeeaf4c..a928addd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1159,7 +1159,7 @@ __metadata: languageName: unknown linkType: soft -"@mittwald/api-client@workspace:packages/mittwald": +"@mittwald/api-client@workspace:^, @mittwald/api-client@workspace:packages/mittwald": version: 0.0.0-use.local resolution: "@mittwald/api-client@workspace:packages/mittwald" dependencies: @@ -1248,6 +1248,40 @@ __metadata: languageName: unknown linkType: soft +"@mittwald/api-models@workspace:packages/models": + version: 0.0.0-use.local + resolution: "@mittwald/api-models@workspace:packages/models" + dependencies: + "@jest/globals": "npm:^29.7.0" + "@mittwald/api-client": "workspace:^" + "@mittwald/react-use-promise": "npm:^2.3.12" + "@types/jest": "npm:^29.5.12" + "@types/react": "npm:^18.2.57" + "@typescript-eslint/eslint-plugin": "npm:^6.21.0" + "@typescript-eslint/parser": "npm:^6.21.0" + another-deep-freeze: "npm:^1.0.0" + eslint: "npm:^8.56.0" + eslint-config-prettier: "npm:^9.1.0" + eslint-plugin-json: "npm:^3.1.0" + eslint-plugin-prettier: "npm:^5.1.3" + jest: "npm:^29.7.0" + polytype: "npm:^0.17.0" + prettier: "npm:^3.2.5" + react: "npm:^18.2.0" + rimraf: "npm:^5.0.5" + ts-jest: "npm:^29.1.2" + type-fest: "npm:^4.10.3" + typescript: "npm:^5.3.3" + peerDependencies: + "@mittwald/react-use-promise": ^2.3.12 + peerDependenciesMeta: + "@mittwald/react-use-promise": + optional: true + react: + optional: true + languageName: unknown + linkType: soft + "@mittwald/react-use-promise@npm:^2.3.12": version: 2.3.12 resolution: "@mittwald/react-use-promise@npm:2.3.12" @@ -2368,6 +2402,17 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^18.2.57": + version: 18.2.60 + resolution: "@types/react@npm:18.2.60" + dependencies: + "@types/prop-types": "npm:*" + "@types/scheduler": "npm:*" + csstype: "npm:^3.0.2" + checksum: 10/5f2f6091623f13375a5bbc7e5c222cd212b5d6366ead737b76c853f6f52b314db24af5ae3f688d2d49814c668c216858a75433f145311839d8989d46bb3cbecf + languageName: node + linkType: hard + "@types/react@npm:^18.2.58": version: 18.2.58 resolution: "@types/react@npm:18.2.58" @@ -2470,6 +2515,31 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/eslint-plugin@npm:^6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/eslint-plugin@npm:6.21.0" + dependencies: + "@eslint-community/regexpp": "npm:^4.5.1" + "@typescript-eslint/scope-manager": "npm:6.21.0" + "@typescript-eslint/type-utils": "npm:6.21.0" + "@typescript-eslint/utils": "npm:6.21.0" + "@typescript-eslint/visitor-keys": "npm:6.21.0" + debug: "npm:^4.3.4" + graphemer: "npm:^1.4.0" + ignore: "npm:^5.2.4" + natural-compare: "npm:^1.4.0" + semver: "npm:^7.5.4" + ts-api-utils: "npm:^1.0.1" + peerDependencies: + "@typescript-eslint/parser": ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/a57de0f630789330204cc1531f86cfc68b391cafb1ba67c8992133f1baa2a09d629df66e71260b040de4c9a3ff1252952037093c4128b0d56c4dbb37720b4c1d + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^7.0.2": version: 7.0.2 resolution: "@typescript-eslint/eslint-plugin@npm:7.0.2" @@ -2495,6 +2565,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/parser@npm:^6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/parser@npm:6.21.0" + dependencies: + "@typescript-eslint/scope-manager": "npm:6.21.0" + "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/typescript-estree": "npm:6.21.0" + "@typescript-eslint/visitor-keys": "npm:6.21.0" + debug: "npm:^4.3.4" + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/4d51cdbc170e72275efc5ef5fce48a81ec431e4edde8374f4d0213d8d370a06823e1a61ae31d502a5f1b0d1f48fc4d29a1b1b5c2dcf809d66d3872ccf6e46ac7 + languageName: node + linkType: hard + "@typescript-eslint/parser@npm:^7.0.2": version: 7.0.2 resolution: "@typescript-eslint/parser@npm:7.0.2" @@ -2513,6 +2601,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/scope-manager@npm:6.21.0" + dependencies: + "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/visitor-keys": "npm:6.21.0" + checksum: 10/fe91ac52ca8e09356a71dc1a2f2c326480f3cccfec6b2b6d9154c1a90651ab8ea270b07c67df5678956c3bbf0bbe7113ab68f68f21b20912ea528b1214197395 + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:7.0.2": version: 7.0.2 resolution: "@typescript-eslint/scope-manager@npm:7.0.2" @@ -2523,6 +2621,23 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/type-utils@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/type-utils@npm:6.21.0" + dependencies: + "@typescript-eslint/typescript-estree": "npm:6.21.0" + "@typescript-eslint/utils": "npm:6.21.0" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^1.0.1" + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/d03fb3ee1caa71f3ce053505f1866268d7ed79ffb7fed18623f4a1253f5b8f2ffc92636d6fd08fcbaf5bd265a6de77bf192c53105131e4724643dfc910d705fc + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:7.0.2": version: 7.0.2 resolution: "@typescript-eslint/type-utils@npm:7.0.2" @@ -2540,6 +2655,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/types@npm:6.21.0" + checksum: 10/e26da86d6f36ca5b6ef6322619f8ec55aabcd7d43c840c977ae13ae2c964c3091fc92eb33730d8be08927c9de38466c5323e78bfb270a9ff1d3611fe821046c5 + languageName: node + linkType: hard + "@typescript-eslint/types@npm:7.0.2": version: 7.0.2 resolution: "@typescript-eslint/types@npm:7.0.2" @@ -2547,6 +2669,25 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/typescript-estree@npm:6.21.0" + dependencies: + "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/visitor-keys": "npm:6.21.0" + debug: "npm:^4.3.4" + globby: "npm:^11.1.0" + is-glob: "npm:^4.0.3" + minimatch: "npm:9.0.3" + semver: "npm:^7.5.4" + ts-api-utils: "npm:^1.0.1" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/b32fa35fca2a229e0f5f06793e5359ff9269f63e9705e858df95d55ca2cd7fdb5b3e75b284095a992c48c5fc46a1431a1a4b6747ede2dd08929dc1cbacc589b8 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:7.0.2": version: 7.0.2 resolution: "@typescript-eslint/typescript-estree@npm:7.0.2" @@ -2566,6 +2707,23 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/utils@npm:6.21.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.4.0" + "@types/json-schema": "npm:^7.0.12" + "@types/semver": "npm:^7.5.0" + "@typescript-eslint/scope-manager": "npm:6.21.0" + "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/typescript-estree": "npm:6.21.0" + semver: "npm:^7.5.4" + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + checksum: 10/b404a2c55a425a79d054346ae123087d30c7ecf7ed7abcf680c47bf70c1de4fabadc63434f3f460b2fa63df76bc9e4a0b9fa2383bb8a9fcd62733fb5c4e4f3e3 + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:7.0.2": version: 7.0.2 resolution: "@typescript-eslint/utils@npm:7.0.2" @@ -2583,6 +2741,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:6.21.0": + version: 6.21.0 + resolution: "@typescript-eslint/visitor-keys@npm:6.21.0" + dependencies: + "@typescript-eslint/types": "npm:6.21.0" + eslint-visitor-keys: "npm:^3.4.1" + checksum: 10/30422cdc1e2ffad203df40351a031254b272f9c6f2b7e02e9bfa39e3fc2c7b1c6130333b0057412968deda17a3a68a578a78929a8139c6acef44d9d841dc72e1 + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:7.0.2": version: 7.0.2 resolution: "@typescript-eslint/visitor-keys@npm:7.0.2" @@ -2876,6 +3044,13 @@ __metadata: languageName: node linkType: hard +"another-deep-freeze@npm:^1.0.0": + version: 1.0.0 + resolution: "another-deep-freeze@npm:1.0.0" + checksum: 10/73dd20db6176a84ea347089e53a0cd36847a3f3f0a39c32ae21b114ae8bfeb2d6b5bb0d60bc0612540af7cd8bfed3a3470583e32540418045211525cc5a9e411 + languageName: node + linkType: hard + "ansi-colors@npm:^4.1.1": version: 4.1.3 resolution: "ansi-colors@npm:4.1.3" @@ -9237,6 +9412,13 @@ __metadata: languageName: node linkType: hard +"polytype@npm:^0.17.0": + version: 0.17.0 + resolution: "polytype@npm:0.17.0" + checksum: 10/f6ebd1752a98f4b27689604fd3b36e60ae1296d84030421bbc7ecb785fd9f3b85e95b2f8aacaf2802ab30c79cb0b47bbf36493a46e8f6b5759ba14fd53ae301a + languageName: node + linkType: hard + "postcss-selector-parser@npm:^6.0.10": version: 6.0.13 resolution: "postcss-selector-parser@npm:6.0.13"