diff --git a/Cargo.lock b/Cargo.lock index 309da9cf..74f85cc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1543,6 +1543,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "flate2" version = "1.1.1" @@ -2922,9 +2933,9 @@ dependencies = [ [[package]] name = "kittycad" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25f584622bc2ae682b0f64115fa9bfa26044ee71a8a7cf3ec8a207d35569202" +checksum = "f77f81460de053657b6d56aa75fd0a8ff25454e1330762bdaa04a3b89a25f38c" dependencies = [ "anyhow", "async-trait", @@ -3073,6 +3084,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall 0.5.11", ] [[package]] @@ -5658,6 +5670,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -7152,6 +7175,16 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "y4m" version = "0.8.0" @@ -7327,6 +7360,7 @@ dependencies = [ "git_rev", "heck", "http 1.4.0", + "ignore", "image", "itertools", "kcl-lib", @@ -7360,6 +7394,7 @@ dependencies = [ "slog-term", "tabled", "tabwriter", + "tar", "tempfile", "terminal_size", "test-context", diff --git a/Cargo.toml b/Cargo.toml index 1c3c6e22..647a5808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ futures = "0.3" git_rev = "0.1.0" heck = "0.5.0" http = "1" +ignore = "0.4" image = { version = "0.25", default-features = false, features = [ "png", "jpeg", @@ -39,7 +40,7 @@ image = { version = "0.25", default-features = false, features = [ itertools = "0.14.0" kcl-lib = { version = "=0.2.139", features = ["disable-println"] } kcl-test-server = "=0.2.139" -kittycad = { version = "0.4.9", features = [ +kittycad = { version = "0.4.10", features = [ "clap", "tabled", "requests", @@ -84,6 +85,7 @@ slog-term = "2" tabled = { version = "0.20.0", features = ["ansi"] } tabwriter = "1.4.1" tempfile = "3.27.0" +tar = "0.4" terminal_size = "0.4.4" thiserror = "2" tokio = { version = "1", features = ["full"] } @@ -119,4 +121,3 @@ debug = 0 [patch.crates-io] # kittycad-modeling-cmds = { git = "https://github.com/KittyCAD/modeling-api", branch = "achalmers/remove-cruft"} -# kcl-lib = { path = "../modeling-app/rust/kcl-lib" } diff --git a/flake.lock b/flake.lock index de14e5b8..500a05be 100644 --- a/flake.lock +++ b/flake.lock @@ -28,11 +28,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1752689277, - "narHash": "sha256-uldUBFkZe/E7qbvxa3mH1ItrWZyT6w1dBKJQF/3ZSsc=", + "lastModified": 1769799857, + "narHash": "sha256-88IFXZ7Sa1vxbz5pty0Io5qEaMQMMUPMonLa3Ls/ss4=", "owner": "nix-community", "repo": "naersk", - "rev": "0e72363d0938b0208d6c646d10649164c43f4d64", + "rev": "9d4ed44d8b8cecdceb1d6fd76e74123d90ae6339", "type": "github" }, "original": { @@ -59,11 +59,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1762604901, - "narHash": "sha256-Pr2jpryIaQr9Yx8p6QssS03wqB6UifnnLr3HJw9veDw=", + "lastModified": 1775095191, + "narHash": "sha256-CsqRiYbgQyv01LS0NlC7shwzhDhjNDQSrhBX8VuD3nM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f6b44b2401525650256b977063dbcf830f762369", + "rev": "106eb93cbb9d4e4726bf6bc367a3114f7ed6b32f", "type": "github" }, "original": { @@ -118,11 +118,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1762915112, - "narHash": "sha256-d9j1g8nKmYDHy+/bIOPQTh9IwjRliqaTM0QLHMV92Ic=", + "lastModified": 1775099554, + "narHash": "sha256-3xBsGnGDLOFtnPZ1D3j2LU19wpAlYefRKTlkv648rU0=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "aa1e85921cfa04de7b6914982a94621fbec5cc02", + "rev": "8d6387ed6d8e6e6672fd3ed4b61b59d44b124d99", "type": "github" }, "original": { diff --git a/spec.json b/spec.json index a259dcd6..4054e32b 100644 --- a/spec.json +++ b/spec.json @@ -16924,7 +16924,7 @@ "/projects/categories": { "get": { "tags": [ - "users" + "projects" ], "summary": "List the active categories available for project submissions.", "operationId": "list_project_categories", @@ -16933,7 +16933,6 @@ "description": "successful operation", "headers": { "Access-Control-Allow-Credentials": { - "description": "Access-Control-Allow-Credentials header.", "style": "simple", "schema": { "nullable": true, @@ -16941,7 +16940,6 @@ } }, "Access-Control-Allow-Headers": { - "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", "style": "simple", "schema": { "nullable": true, @@ -16949,7 +16947,6 @@ } }, "Access-Control-Allow-Methods": { - "description": "Access-Control-Allow-Methods header.", "style": "simple", "schema": { "nullable": true, @@ -16957,15 +16954,20 @@ } }, "Access-Control-Allow-Origin": { - "description": "Access-Control-Allow-Origin header.", "style": "simple", "schema": { "nullable": true, "type": "string" } }, + "Cache-Control": { + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + }, "Content-Location": { - "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", "style": "simple", "schema": { "nullable": true, @@ -16973,7 +16975,6 @@ } }, "Location": { - "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", "style": "simple", "schema": { "nullable": true, @@ -16981,7 +16982,6 @@ } }, "Set-Cookie": { - "description": "Set-Cookie header.", "style": "simple", "schema": { "nullable": true, @@ -16989,7 +16989,6 @@ } }, "X-Api-Call-Id": { - "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", "style": "simple", "required": true, "schema": { @@ -17106,7 +17105,7 @@ "/projects/public": { "get": { "tags": [ - "users" + "projects" ], "summary": "List publicly visible community projects for the website/gallery.", "operationId": "list_public_projects", @@ -17287,7 +17286,7 @@ "/projects/public/{id}/thumbnail": { "get": { "tags": [ - "users" + "projects" ], "summary": "Fetch the public thumbnail for a published project.", "operationId": "get_public_project_thumbnail", @@ -17314,28 +17313,129 @@ } } }, - "/store/coupon": { + "/projects/public/{id}/vote": { "post": { "tags": [ - "store", - "hidden" + "projects" ], - "summary": "Create a new store coupon.", - "description": "This endpoint requires authentication by a Zoo employee. It creates a new store coupon.", - "operationId": "create_store_coupon", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StoreCouponParams" + "summary": "Add the authenticated user's upvote to a published community project.", + "operationId": "create_public_project_vote", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicProjectVoteResponse" + } } } }, - "required": true - }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "projects" + ], + "summary": "Remove the authenticated user's upvote from a published community project.", + "operationId": "delete_public_project_vote", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + } + ], "responses": { - "201": { - "description": "successful creation", + "200": { + "description": "successful operation", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -17405,7 +17505,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DiscountCode" + "$ref": "#/components/schemas/PublicProjectVoteResponse" } } } @@ -17424,10 +17524,21 @@ ], "summary": "OPTIONS endpoint.", "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_store_coupon", + "operationId": "options_public_project_vote", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + } + ], "responses": { "204": { - "description": "resource updated", + "description": "successful operation, no content", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -17504,38 +17615,59 @@ } } }, - "/subscription-plans/{slug}/prices": { - "post": { + "/projects/shared/{key}/download": { + "get": { "tags": [ - "payments", - "hidden" + "hidden", + "projects" ], - "summary": "Create or update a price for a subscription plan.", - "description": "You must be a Zoo admin to perform this request.", - "operationId": "upsert_subscription_plan_price", + "summary": "Download a project using a share link.", + "operationId": "download_shared_project", "parameters": [ { "in": "path", - "name": "slug", + "name": "key", + "description": "Share-link key.", "required": true, "schema": { "type": "string" } } ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, + "/store/coupon": { + "post": { + "tags": [ + "store", + "hidden" + ], + "summary": "Create a new store coupon.", + "description": "This endpoint requires authentication by a Zoo employee. It creates a new store coupon.", + "operationId": "create_store_coupon", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PriceUpsertRequest" + "$ref": "#/components/schemas/StoreCouponParams" } } }, "required": true }, "responses": { - "200": { - "description": "successful operation", + "201": { + "description": "successful creation", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -17605,7 +17737,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubscriptionPlanPriceRecord" + "$ref": "#/components/schemas/DiscountCode" } } } @@ -17624,20 +17756,10 @@ ], "summary": "OPTIONS endpoint.", "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_upsert_subscription_plan_price", - "parameters": [ - { - "in": "path", - "name": "slug", - "required": true, - "schema": { - "type": "string" - } - } - ], + "operationId": "options_store_coupon", "responses": { "204": { - "description": "successful operation, no content", + "description": "resource updated", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -17714,44 +17836,35 @@ } } }, - "/unit/conversion/angle/{input_unit}/{output_unit}": { - "get": { + "/subscription-plans/{slug}/prices": { + "post": { "tags": [ - "unit" + "payments", + "hidden" ], - "summary": "Convert angle units.", - "description": "Convert an angle unit value to another angle unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_angle_unit_conversion", + "summary": "Create or update a price for a subscription plan.", + "description": "You must be a Zoo admin to perform this request.", + "operationId": "upsert_subscription_plan_price", "parameters": [ { "in": "path", - "name": "input_unit", - "description": "The source format of the unit.", - "required": true, - "schema": { - "$ref": "#/components/schemas/UnitAngle" - } - }, - { - "in": "path", - "name": "output_unit", - "description": "The output format of the unit.", - "required": true, - "schema": { - "$ref": "#/components/schemas/UnitAngle" - } - }, - { - "in": "query", - "name": "value", - "description": "The initial value.", + "name": "slug", "required": true, "schema": { - "type": "number", - "format": "double" + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PriceUpsertRequest" + } + } + }, + "required": true + }, "responses": { "200": { "description": "successful operation", @@ -17824,7 +17937,102 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitAngleConversion" + "$ref": "#/components/schemas/SubscriptionPlanPriceRecord" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "options": { + "tags": [ + "hidden" + ], + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_upsert_subscription_plan_price", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "successful operation, no content", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" } } } @@ -17838,14 +18046,14 @@ } } }, - "/unit/conversion/area/{input_unit}/{output_unit}": { + "/unit/conversion/angle/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert area units.", - "description": "Convert an area unit value to another area unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_area_unit_conversion", + "summary": "Convert angle units.", + "description": "Convert an angle unit value to another angle unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_angle_unit_conversion", "parameters": [ { "in": "path", @@ -17853,7 +18061,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitArea" + "$ref": "#/components/schemas/UnitAngle" } }, { @@ -17862,7 +18070,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitArea" + "$ref": "#/components/schemas/UnitAngle" } }, { @@ -17948,7 +18156,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitAreaConversion" + "$ref": "#/components/schemas/UnitAngleConversion" } } } @@ -17962,14 +18170,14 @@ } } }, - "/unit/conversion/current/{input_unit}/{output_unit}": { + "/unit/conversion/area/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert current units.", - "description": "Convert a current unit value to another current unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_current_unit_conversion", + "summary": "Convert area units.", + "description": "Convert an area unit value to another area unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_area_unit_conversion", "parameters": [ { "in": "path", @@ -17977,7 +18185,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitCurrent" + "$ref": "#/components/schemas/UnitArea" } }, { @@ -17986,7 +18194,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitCurrent" + "$ref": "#/components/schemas/UnitArea" } }, { @@ -18072,7 +18280,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitCurrentConversion" + "$ref": "#/components/schemas/UnitAreaConversion" } } } @@ -18086,14 +18294,14 @@ } } }, - "/unit/conversion/energy/{input_unit}/{output_unit}": { + "/unit/conversion/current/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert energy units.", - "description": "Convert a energy unit value to another energy unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_energy_unit_conversion", + "summary": "Convert current units.", + "description": "Convert a current unit value to another current unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_current_unit_conversion", "parameters": [ { "in": "path", @@ -18101,7 +18309,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitEnergy" + "$ref": "#/components/schemas/UnitCurrent" } }, { @@ -18110,7 +18318,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitEnergy" + "$ref": "#/components/schemas/UnitCurrent" } }, { @@ -18196,7 +18404,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitEnergyConversion" + "$ref": "#/components/schemas/UnitCurrentConversion" } } } @@ -18210,14 +18418,14 @@ } } }, - "/unit/conversion/force/{input_unit}/{output_unit}": { + "/unit/conversion/energy/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert force units.", - "description": "Convert a force unit value to another force unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_force_unit_conversion", + "summary": "Convert energy units.", + "description": "Convert a energy unit value to another energy unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_energy_unit_conversion", "parameters": [ { "in": "path", @@ -18225,7 +18433,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitForce" + "$ref": "#/components/schemas/UnitEnergy" } }, { @@ -18234,7 +18442,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitForce" + "$ref": "#/components/schemas/UnitEnergy" } }, { @@ -18320,7 +18528,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitForceConversion" + "$ref": "#/components/schemas/UnitEnergyConversion" } } } @@ -18334,14 +18542,14 @@ } } }, - "/unit/conversion/frequency/{input_unit}/{output_unit}": { + "/unit/conversion/force/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert frequency units.", - "description": "Convert a frequency unit value to another frequency unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_frequency_unit_conversion", + "summary": "Convert force units.", + "description": "Convert a force unit value to another force unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_force_unit_conversion", "parameters": [ { "in": "path", @@ -18349,7 +18557,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitFrequency" + "$ref": "#/components/schemas/UnitForce" } }, { @@ -18358,7 +18566,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitFrequency" + "$ref": "#/components/schemas/UnitForce" } }, { @@ -18444,7 +18652,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitFrequencyConversion" + "$ref": "#/components/schemas/UnitForceConversion" } } } @@ -18458,14 +18666,14 @@ } } }, - "/unit/conversion/length/{input_unit}/{output_unit}": { + "/unit/conversion/frequency/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert length units.", - "description": "Convert a length unit value to another length unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_length_unit_conversion", + "summary": "Convert frequency units.", + "description": "Convert a frequency unit value to another frequency unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_frequency_unit_conversion", "parameters": [ { "in": "path", @@ -18473,7 +18681,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitLength" + "$ref": "#/components/schemas/UnitFrequency" } }, { @@ -18482,7 +18690,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitLength" + "$ref": "#/components/schemas/UnitFrequency" } }, { @@ -18568,7 +18776,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitLengthConversion" + "$ref": "#/components/schemas/UnitFrequencyConversion" } } } @@ -18582,14 +18790,14 @@ } } }, - "/unit/conversion/mass/{input_unit}/{output_unit}": { + "/unit/conversion/length/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert mass units.", - "description": "Convert a mass unit value to another mass unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_mass_unit_conversion", + "summary": "Convert length units.", + "description": "Convert a length unit value to another length unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_length_unit_conversion", "parameters": [ { "in": "path", @@ -18597,7 +18805,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitMass" + "$ref": "#/components/schemas/UnitLength" } }, { @@ -18606,7 +18814,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitMass" + "$ref": "#/components/schemas/UnitLength" } }, { @@ -18692,7 +18900,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitMassConversion" + "$ref": "#/components/schemas/UnitLengthConversion" } } } @@ -18706,14 +18914,14 @@ } } }, - "/unit/conversion/power/{input_unit}/{output_unit}": { + "/unit/conversion/mass/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert power units.", - "description": "Convert a power unit value to another power unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_power_unit_conversion", + "summary": "Convert mass units.", + "description": "Convert a mass unit value to another mass unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_mass_unit_conversion", "parameters": [ { "in": "path", @@ -18721,7 +18929,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitPower" + "$ref": "#/components/schemas/UnitMass" } }, { @@ -18730,7 +18938,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitPower" + "$ref": "#/components/schemas/UnitMass" } }, { @@ -18816,7 +19024,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitPowerConversion" + "$ref": "#/components/schemas/UnitMassConversion" } } } @@ -18830,14 +19038,14 @@ } } }, - "/unit/conversion/pressure/{input_unit}/{output_unit}": { + "/unit/conversion/power/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert pressure units.", - "description": "Convert a pressure unit value to another pressure unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_pressure_unit_conversion", + "summary": "Convert power units.", + "description": "Convert a power unit value to another power unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_power_unit_conversion", "parameters": [ { "in": "path", @@ -18845,7 +19053,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitPressure" + "$ref": "#/components/schemas/UnitPower" } }, { @@ -18854,7 +19062,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitPressure" + "$ref": "#/components/schemas/UnitPower" } }, { @@ -18940,7 +19148,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitPressureConversion" + "$ref": "#/components/schemas/UnitPowerConversion" } } } @@ -18954,14 +19162,14 @@ } } }, - "/unit/conversion/temperature/{input_unit}/{output_unit}": { + "/unit/conversion/pressure/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert temperature units.", - "description": "Convert a temperature unit value to another temperature unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_temperature_unit_conversion", + "summary": "Convert pressure units.", + "description": "Convert a pressure unit value to another pressure unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_pressure_unit_conversion", "parameters": [ { "in": "path", @@ -18969,7 +19177,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitTemperature" + "$ref": "#/components/schemas/UnitPressure" } }, { @@ -18978,7 +19186,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitTemperature" + "$ref": "#/components/schemas/UnitPressure" } }, { @@ -19064,7 +19272,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitTemperatureConversion" + "$ref": "#/components/schemas/UnitPressureConversion" } } } @@ -19078,14 +19286,14 @@ } } }, - "/unit/conversion/torque/{input_unit}/{output_unit}": { + "/unit/conversion/temperature/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert torque units.", - "description": "Convert a torque unit value to another torque unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_torque_unit_conversion", + "summary": "Convert temperature units.", + "description": "Convert a temperature unit value to another temperature unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_temperature_unit_conversion", "parameters": [ { "in": "path", @@ -19093,7 +19301,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitTorque" + "$ref": "#/components/schemas/UnitTemperature" } }, { @@ -19102,7 +19310,7 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitTorque" + "$ref": "#/components/schemas/UnitTemperature" } }, { @@ -19188,7 +19396,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnitTorqueConversion" + "$ref": "#/components/schemas/UnitTemperatureConversion" } } } @@ -19202,14 +19410,14 @@ } } }, - "/unit/conversion/volume/{input_unit}/{output_unit}": { + "/unit/conversion/torque/{input_unit}/{output_unit}": { "get": { "tags": [ "unit" ], - "summary": "Convert volume units.", - "description": "Convert a volume unit value to another volume unit value. This is a nice endpoint to use for helper functions.", - "operationId": "get_volume_unit_conversion", + "summary": "Convert torque units.", + "description": "Convert a torque unit value to another torque unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_torque_unit_conversion", "parameters": [ { "in": "path", @@ -19217,7 +19425,7 @@ "description": "The source format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitVolume" + "$ref": "#/components/schemas/UnitTorque" } }, { @@ -19226,7 +19434,131 @@ "description": "The output format of the unit.", "required": true, "schema": { - "$ref": "#/components/schemas/UnitVolume" + "$ref": "#/components/schemas/UnitTorque" + } + }, + { + "in": "query", + "name": "value", + "description": "The initial value.", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnitTorqueConversion" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/unit/conversion/volume/{input_unit}/{output_unit}": { + "get": { + "tags": [ + "unit" + ], + "summary": "Convert volume units.", + "description": "Convert a volume unit value to another volume unit value. This is a nice endpoint to use for helper functions.", + "operationId": "get_volume_unit_conversion", + "parameters": [ + { + "in": "path", + "name": "input_unit", + "description": "The source format of the unit.", + "required": true, + "schema": { + "$ref": "#/components/schemas/UnitVolume" + } + }, + { + "in": "path", + "name": "output_unit", + "description": "The output format of the unit.", + "required": true, + "schema": { + "$ref": "#/components/schemas/UnitVolume" } }, { @@ -24624,10 +24956,10 @@ "/user/projects": { "get": { "tags": [ - "users" + "projects" ], "summary": "List the authenticated user's projects.", - "operationId": "list_user_projects", + "operationId": "list_projects", "responses": { "200": { "description": "successful operation", @@ -24719,10 +25051,10 @@ }, "post": { "tags": [ - "users" + "projects" ], "summary": "Create a draft project for the authenticated user.", - "operationId": "create_user_project", + "operationId": "create_project", "requestBody": { "content": { "multipart/form-data": { @@ -24825,7 +25157,7 @@ ], "summary": "OPTIONS endpoint.", "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_user_projects", + "operationId": "options_projects", "responses": { "204": { "description": "resource updated", @@ -24908,10 +25240,10 @@ "/user/projects/{id}": { "get": { "tags": [ - "users" + "projects" ], "summary": "Get one of the authenticated user's projects.", - "operationId": "get_user_project", + "operationId": "get_project", "parameters": [ { "in": "path", @@ -25010,10 +25342,10 @@ }, "put": { "tags": [ - "users" + "projects" ], "summary": "Replace one of the authenticated user's projects.", - "operationId": "update_user_project", + "operationId": "update_project", "parameters": [ { "in": "path", @@ -25121,13 +25453,12 @@ } } }, - "options": { + "delete": { "tags": [ - "hidden" + "projects" ], - "summary": "OPTIONS endpoint.", - "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_user_project", + "summary": "Delete one of the authenticated user's projects.", + "operationId": "delete_project", "parameters": [ { "in": "path", @@ -25141,7 +25472,7 @@ ], "responses": { "204": { - "description": "successful operation, no content", + "description": "successful deletion", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -25216,45 +25547,14 @@ "$ref": "#/components/responses/Error" } } - } - }, - "/user/projects/{id}/download": { - "get": { - "tags": [ - "users" - ], - "summary": "Download one of the authenticated user's projects as a tar archive.", - "operationId": "download_user_project", - "parameters": [ - { - "in": "path", - "name": "id", - "description": "The identifier.", - "required": true, - "schema": { - "$ref": "#/components/schemas/Uuid" - } - } - ], - "responses": { - "default": { - "description": "", - "content": { - "*/*": { - "schema": {} - } - } - } - } - } - }, - "/user/projects/{id}/publish": { - "post": { + }, + "options": { "tags": [ - "users" + "hidden" ], - "summary": "Submit one of the authenticated user's projects for public review.", - "operationId": "publish_user_project", + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_project", "parameters": [ { "in": "path", @@ -25267,8 +25567,8 @@ } ], "responses": { - "200": { - "description": "successful operation", + "204": { + "description": "successful operation, no content", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -25334,13 +25634,6 @@ "type": "string" } } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectResponse" - } - } } }, "4XX": { @@ -25350,14 +25643,15 @@ "$ref": "#/components/responses/Error" } } - }, - "options": { + } + }, + "/user/projects/{id}/download": { + "get": { "tags": [ - "hidden" + "projects" ], - "summary": "OPTIONS endpoint.", - "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_publish_user_project", + "summary": "Download one of the authenticated user's projects as a tar archive.", + "operationId": "download_project", "parameters": [ { "in": "path", @@ -25370,8 +25664,38 @@ } ], "responses": { - "204": { - "description": "successful operation, no content", + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, + "/user/projects/{id}/publish": { + "post": { + "tags": [ + "projects" + ], + "summary": "Submit one of the authenticated user's projects for public review.", + "operationId": "publish_project", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -25437,6 +25761,13 @@ "type": "string" } } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectResponse" + } + } } }, "4XX": { @@ -25446,30 +25777,28 @@ "$ref": "#/components/responses/Error" } } - } - }, - "/user/session/{token}": { - "get": { + }, + "options": { "tags": [ - "users" + "hidden" ], - "summary": "Get a session for your user.", - "description": "This endpoint requires authentication by any Zoo user. It returns details of the requested API token for the user.", - "operationId": "get_session_for_user", + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_publish_project", "parameters": [ { "in": "path", - "name": "token", - "description": "The API token.", + "name": "id", + "description": "The identifier.", "required": true, "schema": { - "$ref": "#/components/schemas/SessionUuid" + "$ref": "#/components/schemas/Uuid" } } ], "responses": { - "200": { - "description": "successful operation", + "204": { + "description": "successful operation, no content", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -25535,13 +25864,6 @@ "type": "string" } } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } } }, "4XX": { @@ -25553,41 +25875,21 @@ } } }, - "/user/shortlinks": { + "/user/projects/{id}/share-links": { "get": { "tags": [ - "users", - "shortlinks" + "projects" ], - "summary": "Get the shortlinks for a user.", - "description": "This endpoint requires authentication by any Zoo user. It gets the shortlinks for the user.", - "operationId": "get_user_shortlinks", + "summary": "List share links for one of the authenticated user's projects.", + "operationId": "list_project_share_links", "parameters": [ { - "in": "query", - "name": "limit", - "description": "Maximum number of items returned by a single call", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 1 - } - }, - { - "in": "query", - "name": "page_token", - "description": "Token returned by previous call to retrieve the subsequent page", - "schema": { - "nullable": true, - "type": "string" - } - }, - { - "in": "query", - "name": "sort_by", + "in": "path", + "name": "id", + "description": "The identifier.", + "required": true, "schema": { - "$ref": "#/components/schemas/CreatedAtSortMode" + "$ref": "#/components/schemas/Uuid" } } ], @@ -25663,7 +25965,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ShortlinkResultsPage" + "title": "Array_of_ProjectShareLinkResponse", + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectShareLinkResponse" + } } } } @@ -25674,24 +25980,30 @@ "5XX": { "$ref": "#/components/responses/Error" } - }, - "x-dropshot-pagination": { - "required": [] } }, "post": { "tags": [ - "users", - "shortlinks" + "projects" + ], + "summary": "Create a share link for one of the authenticated user's projects.", + "operationId": "create_project_share_link", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + } ], - "summary": "Create a shortlink for a user.", - "description": "This endpoint requires authentication by any Zoo user. It creates a shortlink for the user.", - "operationId": "create_user_shortlink", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateShortlinkRequest" + "$ref": "#/components/schemas/CreateProjectShareLinkRequest" } } }, @@ -25769,7 +26081,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateShortlinkResponse" + "$ref": "#/components/schemas/ProjectShareLinkResponse" } } } @@ -25788,10 +26100,21 @@ ], "summary": "OPTIONS endpoint.", "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_user_shortlinks", + "operationId": "options_project_share_links", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + } + ], "responses": { "204": { - "description": "resource updated", + "description": "successful operation, no content", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -25868,21 +26191,27 @@ } } }, - "/user/shortlinks/{key}": { - "get": { + "/user/projects/{id}/share-links/{key}": { + "delete": { "tags": [ - "hidden", - "users", - "shortlinks" + "projects" ], - "summary": "Redirect the user to the URL for the shortlink.", - "description": "This endpoint might require authentication by a Zoo user. It gets the shortlink for the user and redirects them to the URL. If the shortlink is owned by an org, the user must be a member of the org.", - "operationId": "redirect_user_shortlink", + "summary": "Delete one share link for one of the authenticated user's projects.", + "operationId": "delete_project_share_link", "parameters": [ + { + "in": "path", + "name": "id", + "description": "Project identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, { "in": "path", "name": "key", - "description": "The key of the shortlink.", + "description": "Share-link key.", "required": true, "schema": { "type": "string" @@ -25890,8 +26219,8 @@ } ], "responses": { - "302": { - "description": "Temporary Redirect", + "204": { + "description": "resource updated", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -25967,38 +26296,134 @@ } } }, - "put": { + "options": { "tags": [ - "users", - "shortlinks" + "hidden" ], - "summary": "Update a shortlink for a user.", - "description": "This endpoint requires authentication by any Zoo user. It updates a shortlink for the user.\n\nThis endpoint really only allows you to change the `restrict_to_org` setting of a shortlink. Thus it is only useful for folks who are part of an org. If you are not part of an org, you will not be able to change the `restrict_to_org` status.", - "operationId": "update_user_shortlink", + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_delete_project_share_link", "parameters": [ + { + "in": "path", + "name": "id", + "description": "Project identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Uuid" + } + }, { "in": "path", "name": "key", - "description": "The key of the shortlink.", + "description": "Share-link key.", "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateShortlinkRequest" + "responses": { + "204": { + "description": "successful operation, no content", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } } } }, - "required": true - }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/user/session/{token}": { + "get": { + "tags": [ + "users" + ], + "summary": "Get a session for your user.", + "description": "This endpoint requires authentication by any Zoo user. It returns details of the requested API token for the user.", + "operationId": "get_session_for_user", + "parameters": [ + { + "in": "path", + "name": "token", + "description": "The API token.", + "required": true, + "schema": { + "$ref": "#/components/schemas/SessionUuid" + } + } + ], "responses": { - "204": { - "description": "resource updated", + "200": { + "description": "successful operation", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -26064,6 +26489,13 @@ "type": "string" } } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } } }, "4XX": { @@ -26073,29 +26505,49 @@ "$ref": "#/components/responses/Error" } } - }, - "delete": { + } + }, + "/user/shortlinks": { + "get": { "tags": [ "users", "shortlinks" ], - "summary": "Delete a shortlink for a user.", - "description": "This endpoint requires authentication by any Zoo user. It deletes a shortlink for the user.", - "operationId": "delete_user_shortlink", + "summary": "Get the shortlinks for a user.", + "description": "This endpoint requires authentication by any Zoo user. It gets the shortlinks for the user.", + "operationId": "get_user_shortlinks", "parameters": [ { - "in": "path", - "name": "key", - "description": "The key of the shortlink.", - "required": true, + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", "schema": { + "nullable": true, "type": "string" } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/CreatedAtSortMode" + } } ], "responses": { - "204": { - "description": "resource updated", + "200": { + "description": "successful operation", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -26161,6 +26613,13 @@ "type": "string" } } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShortlinkResultsPage" + } + } } }, "4XX": { @@ -26169,29 +26628,32 @@ "5XX": { "$ref": "#/components/responses/Error" } + }, + "x-dropshot-pagination": { + "required": [] } }, - "options": { + "post": { "tags": [ - "hidden" + "users", + "shortlinks" ], - "summary": "OPTIONS endpoint.", - "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_delete_user_shortlinks", - "parameters": [ - { - "in": "path", - "name": "key", - "description": "The key of the shortlink.", - "required": true, - "schema": { - "type": "string" + "summary": "Create a shortlink for a user.", + "description": "This endpoint requires authentication by any Zoo user. It creates a shortlink for the user.", + "operationId": "create_user_shortlink", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateShortlinkRequest" + } } - } - ], + }, + "required": true + }, "responses": { - "204": { - "description": "successful operation, no content", + "201": { + "description": "successful creation", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -26257,6 +26719,13 @@ "type": "string" } } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateShortlinkResponse" + } + } } }, "4XX": { @@ -26266,74 +26735,17 @@ "$ref": "#/components/responses/Error" } } - } - }, - "/user/text-to-cad": { - "get": { + }, + "options": { "tags": [ - "ml" - ], - "summary": "List text-to-CAD parts you've generated.", - "description": "This will always return the STEP file contents as well as the format the user originally requested.\n\nThis endpoint requires authentication by any Zoo user. It returns the text-to-CAD parts for the authenticated user.\n\nThe text-to-CAD parts are returned in order of creation, with the most recently created text-to-CAD parts first.", - "operationId": "list_text_to_cad_parts_for_user", - "parameters": [ - { - "in": "query", - "name": "limit", - "description": "Maximum number of items returned by a single call", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 1 - } - }, - { - "in": "query", - "name": "page_token", - "description": "Token returned by previous call to retrieve the subsequent page", - "schema": { - "nullable": true, - "type": "string" - } - }, - { - "in": "query", - "name": "sort_by", - "schema": { - "$ref": "#/components/schemas/CreatedAtSortMode" - } - }, - { - "in": "query", - "name": "no_models", - "description": "DEPRECATED: This is the same as `no_parts`, and will be dropped in a future release. Please do not use this.", - "schema": { - "nullable": true, - "type": "boolean" - } - }, - { - "in": "query", - "name": "no_parts", - "description": "If we should return the part contents or just the metadata.", - "schema": { - "nullable": true, - "type": "boolean" - } - }, - { - "in": "query", - "name": "conversation_id", - "description": "If specified, only return the prompts for the conversation id given.", - "schema": { - "$ref": "#/components/schemas/Uuid" - } - } + "hidden" ], + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_user_shortlinks", "responses": { - "200": { - "description": "successful operation", + "204": { + "description": "resource updated", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -26399,13 +26811,6 @@ "type": "string" } } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TextToCadResponseResultsPage" - } - } } }, "4XX": { @@ -26414,35 +26819,33 @@ "5XX": { "$ref": "#/components/responses/Error" } - }, - "x-dropshot-pagination": { - "required": [] } } }, - "/user/text-to-cad/{id}": { + "/user/shortlinks/{key}": { "get": { "tags": [ - "ml" + "hidden", + "users", + "shortlinks" ], - "summary": "Get a text-to-CAD response.", - "description": "This endpoint requires authentication by any Zoo user. The user must be the owner of the text-to-CAD model.", - "operationId": "get_text_to_cad_part_for_user", + "summary": "Redirect the user to the URL for the shortlink.", + "description": "This endpoint might require authentication by a Zoo user. It gets the shortlink for the user and redirects them to the URL. If the shortlink is owned by an org, the user must be a member of the org.", + "operationId": "redirect_user_shortlink", "parameters": [ { "in": "path", - "name": "id", - "description": "The id of the model to give feedback to.", + "name": "key", + "description": "The key of the shortlink.", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { - "200": { - "description": "successful operation", + "302": { + "description": "Temporary Redirect", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -26508,13 +26911,6 @@ "type": "string" } } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TextToCadResponse" - } - } } }, "4XX": { @@ -26525,34 +26921,35 @@ } } }, - "post": { + "put": { "tags": [ - "ml" + "users", + "shortlinks" ], - "summary": "Give feedback to a specific ML response.", - "description": "This can be a text-to-CAD creation or iteration.\n\nThis endpoint requires authentication by any Zoo user. The user must be the owner of the ML response, in order to give feedback.", - "operationId": "create_text_to_cad_part_feedback", + "summary": "Update a shortlink for a user.", + "description": "This endpoint requires authentication by any Zoo user. It updates a shortlink for the user.\n\nThis endpoint really only allows you to change the `restrict_to_org` setting of a shortlink. Thus it is only useful for folks who are part of an org. If you are not part of an org, you will not be able to change the `restrict_to_org` status.", + "operationId": "update_user_shortlink", "parameters": [ { "in": "path", - "name": "id", - "description": "The id of the model to give feedback to.", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - { - "in": "query", - "name": "feedback", - "description": "The feedback.", + "name": "key", + "description": "The key of the shortlink.", "required": true, "schema": { - "$ref": "#/components/schemas/MlFeedback" + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateShortlinkRequest" + } + } + }, + "required": true + }, "responses": { "204": { "description": "resource updated", @@ -26631,28 +27028,28 @@ } } }, - "options": { + "delete": { "tags": [ - "hidden" + "users", + "shortlinks" ], - "summary": "OPTIONS endpoint.", - "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_text_to_cad_part_feedback", + "summary": "Delete a shortlink for a user.", + "description": "This endpoint requires authentication by any Zoo user. It deletes a shortlink for the user.", + "operationId": "delete_user_shortlink", "parameters": [ { "in": "path", - "name": "id", - "description": "The id of the model to give feedback to.", + "name": "key", + "description": "The key of the shortlink.", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "204": { - "description": "successful operation, no content", + "description": "resource updated", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -26727,49 +27124,28 @@ "$ref": "#/components/responses/Error" } } - } - }, - "/users": { - "get": { + }, + "options": { "tags": [ - "users", "hidden" ], - "summary": "List users.", - "description": "This endpoint requires authentication by a Zoo employee. The users are returned in order of creation, with the most recently created users first.", - "operationId": "list_users", + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_delete_user_shortlinks", "parameters": [ { - "in": "query", - "name": "limit", - "description": "Maximum number of items returned by a single call", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 1 - } - }, - { - "in": "query", - "name": "page_token", - "description": "Token returned by previous call to retrieve the subsequent page", + "in": "path", + "name": "key", + "description": "The key of the shortlink.", + "required": true, "schema": { - "nullable": true, "type": "string" } - }, - { - "in": "query", - "name": "sort_by", - "schema": { - "$ref": "#/components/schemas/CreatedAtSortMode" - } } ], "responses": { - "200": { - "description": "successful operation", + "204": { + "description": "successful operation, no content", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -26835,13 +27211,6 @@ "type": "string" } } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserResponseResultsPage" - } - } } }, "4XX": { @@ -26850,21 +27219,17 @@ "5XX": { "$ref": "#/components/responses/Error" } - }, - "x-dropshot-pagination": { - "required": [] } } }, - "/users-extended": { + "/user/text-to-cad": { "get": { "tags": [ - "users", - "hidden" + "ml" ], - "summary": "List users with extended information.", - "description": "This endpoint requires authentication by a Zoo employee. The users are returned in order of creation, with the most recently created users first.", - "operationId": "list_users_extended", + "summary": "List text-to-CAD parts you've generated.", + "description": "This will always return the STEP file contents as well as the format the user originally requested.\n\nThis endpoint requires authentication by any Zoo user. It returns the text-to-CAD parts for the authenticated user.\n\nThe text-to-CAD parts are returned in order of creation, with the most recently created text-to-CAD parts first.", + "operationId": "list_text_to_cad_parts_for_user", "parameters": [ { "in": "query", @@ -26892,6 +27257,32 @@ "schema": { "$ref": "#/components/schemas/CreatedAtSortMode" } + }, + { + "in": "query", + "name": "no_models", + "description": "DEPRECATED: This is the same as `no_parts`, and will be dropped in a future release. Please do not use this.", + "schema": { + "nullable": true, + "type": "boolean" + } + }, + { + "in": "query", + "name": "no_parts", + "description": "If we should return the part contents or just the metadata.", + "schema": { + "nullable": true, + "type": "boolean" + } + }, + { + "in": "query", + "name": "conversation_id", + "description": "If specified, only return the prompts for the conversation id given.", + "schema": { + "$ref": "#/components/schemas/Uuid" + } } ], "responses": { @@ -26966,7 +27357,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtendedUserResultsPage" + "$ref": "#/components/schemas/TextToCadResponseResultsPage" } } } @@ -26983,23 +27374,23 @@ } } }, - "/users-extended/{id}": { + "/user/text-to-cad/{id}": { "get": { "tags": [ - "users", - "hidden" + "ml" ], - "summary": "Get extended information about a user.", - "description": "To get information about yourself, use `/users-extended/me` as the endpoint. By doing so you will get the user information for the authenticated user.\n\nAlternatively, to get information about the authenticated user, use `/user/extended` endpoint.", - "operationId": "get_user_extended", + "summary": "Get a text-to-CAD response.", + "description": "This endpoint requires authentication by any Zoo user. The user must be the owner of the text-to-CAD model.", + "operationId": "get_text_to_cad_part_for_user", "parameters": [ { "in": "path", "name": "id", - "description": "The user's identifier (uuid or email).", + "description": "The id of the model to give feedback to.", "required": true, "schema": { - "$ref": "#/components/schemas/UserIdentifier" + "type": "string", + "format": "uuid" } } ], @@ -27075,7 +27466,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtendedUser" + "$ref": "#/components/schemas/TextToCadResponse" } } } @@ -27087,31 +27478,38 @@ "$ref": "#/components/responses/Error" } } - } - }, - "/users/{id}": { - "get": { + }, + "post": { "tags": [ - "users", - "hidden" + "ml" ], - "summary": "Get a user.", - "description": "To get information about yourself, use `/users/me` as the endpoint. By doing so you will get the user information for the authenticated user.\n\nAlternatively, to get information about the authenticated user, use `/user` endpoint.", - "operationId": "get_user", + "summary": "Give feedback to a specific ML response.", + "description": "This can be a text-to-CAD creation or iteration.\n\nThis endpoint requires authentication by any Zoo user. The user must be the owner of the ML response, in order to give feedback.", + "operationId": "create_text_to_cad_part_feedback", "parameters": [ { "in": "path", "name": "id", - "description": "The user's identifier (uuid or email).", + "description": "The id of the model to give feedback to.", "required": true, "schema": { - "$ref": "#/components/schemas/UserIdentifier" + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "feedback", + "description": "The feedback.", + "required": true, + "schema": { + "$ref": "#/components/schemas/MlFeedback" } } ], "responses": { - "200": { - "description": "successful operation", + "204": { + "description": "resource updated", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -27177,11 +27575,101 @@ "type": "string" } } - }, - "content": { - "application/json": { + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "options": { + "tags": [ + "hidden" + ], + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_text_to_cad_part_feedback", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The id of the model to give feedback to.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "successful operation, no content", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", "schema": { - "$ref": "#/components/schemas/UserResponse" + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" } } } @@ -27195,23 +27683,41 @@ } } }, - "/users/{id}/admin/details": { + "/users": { "get": { "tags": [ "users", "hidden" ], - "summary": "Get admin-only details for a user.", - "description": "Zoo admins can retrieve extended information about any user, while non-admins receive a 404 to avoid leaking the existence of the resource.", - "operationId": "user_admin_details_get", + "summary": "List users.", + "description": "This endpoint requires authentication by a Zoo employee. The users are returned in order of creation, with the most recently created users first.", + "operationId": "list_users", "parameters": [ { - "in": "path", - "name": "id", - "description": "The user's identifier (uuid or email).", - "required": true, + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", "schema": { - "$ref": "#/components/schemas/UserIdentifier" + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/CreatedAtSortMode" } } ], @@ -27287,7 +27793,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserAdminDetails" + "$ref": "#/components/schemas/UserResponseResultsPage" } } } @@ -27298,28 +27804,476 @@ "5XX": { "$ref": "#/components/responses/Error" } + }, + "x-dropshot-pagination": { + "required": [] } } }, - "/users/{id}/api-calls": { + "/users-extended": { "get": { "tags": [ - "api-calls", + "users", "hidden" ], - "summary": "List API calls for a user.", - "description": "This endpoint requires authentication by any Zoo user. It returns the API calls for the authenticated user if \"me\" is passed as the user id.\n\nAlternatively, you can use the `/user/api-calls` endpoint to get the API calls for your user.\n\nIf the authenticated user is a Zoo employee, then the API calls are returned for the user specified by the user id.\n\nThe API calls are returned in order of creation, with the most recently created API calls first.", - "operationId": "list_api_calls_for_user", + "summary": "List users with extended information.", + "description": "This endpoint requires authentication by a Zoo employee. The users are returned in order of creation, with the most recently created users first.", + "operationId": "list_users_extended", "parameters": [ - { - "in": "path", - "name": "id", - "description": "The user's identifier (uuid or email).", - "required": true, - "schema": { - "$ref": "#/components/schemas/UserIdentifier" - } - }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/CreatedAtSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtendedUserResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, + "/users-extended/{id}": { + "get": { + "tags": [ + "users", + "hidden" + ], + "summary": "Get extended information about a user.", + "description": "To get information about yourself, use `/users-extended/me` as the endpoint. By doing so you will get the user information for the authenticated user.\n\nAlternatively, to get information about the authenticated user, use `/user/extended` endpoint.", + "operationId": "get_user_extended", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The user's identifier (uuid or email).", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserIdentifier" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtendedUser" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/users/{id}": { + "get": { + "tags": [ + "users", + "hidden" + ], + "summary": "Get a user.", + "description": "To get information about yourself, use `/users/me` as the endpoint. By doing so you will get the user information for the authenticated user.\n\nAlternatively, to get information about the authenticated user, use `/user` endpoint.", + "operationId": "get_user", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The user's identifier (uuid or email).", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserIdentifier" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/users/{id}/admin/details": { + "get": { + "tags": [ + "users", + "hidden" + ], + "summary": "Get admin-only details for a user.", + "description": "Zoo admins can retrieve extended information about any user, while non-admins receive a 404 to avoid leaking the existence of the resource.", + "operationId": "user_admin_details_get", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The user's identifier (uuid or email).", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserIdentifier" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminDetails" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/users/{id}/api-calls": { + "get": { + "tags": [ + "api-calls", + "hidden" + ], + "summary": "List API calls for a user.", + "description": "This endpoint requires authentication by any Zoo user. It returns the API calls for the authenticated user if \"me\" is passed as the user id.\n\nAlternatively, you can use the `/user/api-calls` endpoint to get the API calls for your user.\n\nIf the authenticated user is a Zoo employee, then the API calls are returned for the user specified by the user id.\n\nThe API calls are returned in order of creation, with the most recently created API calls first.", + "operationId": "list_api_calls_for_user", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The user's identifier (uuid or email).", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserIdentifier" + } + }, { "in": "query", "name": "limit", @@ -27673,7 +28627,341 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CustomerBalance" + "$ref": "#/components/schemas/CustomerBalance" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "tags": [ + "payments", + "hidden" + ], + "summary": "Update balance for an user.", + "description": "This endpoint requires authentication by a Zoo employee. It updates the balance information for the specified user.", + "operationId": "update_payment_balance_for_any_user", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The user's identifier (uuid or email).", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserIdentifier" + } + }, + { + "in": "query", + "name": "include_total_due", + "description": "If you would like to return the total due for a user. This makes the API call take longer so it is off by default.", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePaymentBalance" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerBalance" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "options": { + "tags": [ + "hidden" + ], + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_payment_balance_for_any_user", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The user's identifier (uuid or email).", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserIdentifier" + } + } + ], + "responses": { + "204": { + "description": "successful operation, no content", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/users/{id}/payment/subscriptions": { + "put": { + "tags": [ + "users", + "hidden" + ], + "summary": "Update a subscription for a user.", + "description": "You must be a Zoo admin to perform this request.", + "operationId": "update_subscription_for_user", + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The user's identifier (uuid or email).", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserIdentifier" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZooProductSubscriptionsUserRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZooProductSubscriptions" } } } @@ -27686,14 +28974,13 @@ } } }, - "put": { + "options": { "tags": [ - "payments", "hidden" ], - "summary": "Update balance for an user.", - "description": "This endpoint requires authentication by a Zoo employee. It updates the balance information for the specified user.", - "operationId": "update_payment_balance_for_any_user", + "summary": "OPTIONS endpoint.", + "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", + "operationId": "options_update_subscription_for_user", "parameters": [ { "in": "path", @@ -27703,29 +28990,108 @@ "schema": { "$ref": "#/components/schemas/UserIdentifier" } - }, - { - "in": "query", - "name": "include_total_due", - "description": "If you would like to return the total due for a user. This makes the API call take longer so it is off by default.", - "schema": { - "type": "boolean" + } + ], + "responses": { + "204": { + "description": "successful operation, no content", + "headers": { + "Access-Control-Allow-Credentials": { + "description": "Access-Control-Allow-Credentials header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "description": "Access-Control-Allow-Headers header. This is a comma-separated list of headers.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "description": "Access-Control-Allow-Methods header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "description": "Access-Control-Allow-Origin header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Content-Location": { + "description": "The Content-Location header for responses that are not the final destination. This is used to indicate where the resource can be found, when it is finished.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Location": { + "description": "The location header for redirects and letting users know if there is a websocket they can listen to for status updates on their operation.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "Set-Cookie": { + "description": "Set-Cookie header.", + "style": "simple", + "schema": { + "nullable": true, + "type": "string" + } + }, + "X-Api-Call-Id": { + "description": "ID for this request. We return it so that users can report this to us and help us debug their problems.", + "style": "simple", + "required": true, + "schema": { + "type": "string" + } + } } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" } + } + } + }, + "/website/email-marketing-consent/request": { + "put": { + "tags": [ + "users", + "hidden" ], + "summary": "Requests public email marketing consent for an email address.", + "operationId": "put_public_email_marketing_consent_request", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdatePaymentBalance" + "$ref": "#/components/schemas/PublicEmailMarketingConsentRequest" } } }, "required": true }, "responses": { - "200": { - "description": "successful operation", + "204": { + "description": "successful operation, no content", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -27791,13 +29157,6 @@ "type": "string" } } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CustomerBalance" - } - } } }, "4XX": { @@ -27814,21 +29173,10 @@ ], "summary": "OPTIONS endpoint.", "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_payment_balance_for_any_user", - "parameters": [ - { - "in": "path", - "name": "id", - "description": "The user's identifier (uuid or email).", - "required": true, - "schema": { - "$ref": "#/components/schemas/UserIdentifier" - } - } - ], + "operationId": "options_put_public_email_marketing_consent_request", "responses": { "204": { - "description": "successful operation, no content", + "description": "resource updated", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -27905,23 +29253,22 @@ } } }, - "/users/{id}/payment/subscriptions": { + "/website/email-marketing-lists/{slug}/subscribe": { "put": { "tags": [ "users", "hidden" ], - "summary": "Update a subscription for a user.", - "description": "You must be a Zoo admin to perform this request.", - "operationId": "update_subscription_for_user", + "summary": "Publicly subscribe an email address to a mailing list by slug.", + "operationId": "put_public_mailing_list_subscribe", "parameters": [ { "in": "path", - "name": "id", - "description": "The user's identifier (uuid or email).", + "name": "slug", + "description": "Stable public list slug.", "required": true, "schema": { - "$ref": "#/components/schemas/UserIdentifier" + "type": "string" } } ], @@ -27929,15 +29276,15 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ZooProductSubscriptionsUserRequest" + "$ref": "#/components/schemas/PublicMailingListMembershipRequest" } } }, "required": true }, "responses": { - "200": { - "description": "successful operation", + "204": { + "description": "successful operation, no content", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -28003,13 +29350,6 @@ "type": "string" } } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ZooProductSubscriptions" - } - } } }, "4XX": { @@ -28026,15 +29366,15 @@ ], "summary": "OPTIONS endpoint.", "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_update_subscription_for_user", + "operationId": "options_put_public_mailing_list_subscribe", "parameters": [ { "in": "path", - "name": "id", - "description": "The user's identifier (uuid or email).", + "name": "slug", + "description": "Stable public list slug.", "required": true, "schema": { - "$ref": "#/components/schemas/UserIdentifier" + "type": "string" } } ], @@ -28117,19 +29457,30 @@ } } }, - "/website/email-marketing-consent/request": { + "/website/email-marketing-lists/{slug}/unsubscribe": { "put": { "tags": [ "users", "hidden" ], - "summary": "Requests public email marketing consent for an email address.", - "operationId": "put_public_email_marketing_consent_request", + "summary": "Publicly remove an email address from a mailing list by slug.", + "operationId": "put_public_mailing_list_unsubscribe", + "parameters": [ + { + "in": "path", + "name": "slug", + "description": "Stable public list slug.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicEmailMarketingConsentRequest" + "$ref": "#/components/schemas/PublicMailingListMembershipRequest" } } }, @@ -28219,10 +29570,21 @@ ], "summary": "OPTIONS endpoint.", "description": "This is necessary for some preflight requests, specifically POST, PUT, and DELETE.", - "operationId": "options_put_public_email_marketing_consent_request", + "operationId": "options_put_public_mailing_list_unsubscribe", + "parameters": [ + { + "in": "path", + "name": "slug", + "description": "Stable public list slug.", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "204": { - "description": "resource updated", + "description": "successful operation, no content", "headers": { "Access-Control-Allow-Credentials": { "description": "Access-Control-Allow-Credentials header.", @@ -33865,6 +35227,21 @@ "source" ] }, + "CreateProjectShareLinkRequest": { + "description": "Request payload for creating a new project share link.", + "type": "object", + "properties": { + "access_mode": { + "description": "Access policy for the generated share link.", + "default": "anyone_with_link", + "allOf": [ + { + "$ref": "#/components/schemas/KclProjectShareLinkAccessMode" + } + ] + } + } + }, "CreateRegion": { "description": "The response from the 'CreateRegion'. The region should have an ID taken from the ID of the 'CreateRegion' modeling command.", "type": "object", @@ -37841,6 +39218,25 @@ } ] }, + "KclProjectShareLinkAccessMode": { + "description": "Access policy for a shared project download link.", + "oneOf": [ + { + "description": "Anyone holding the URL can download the project.", + "type": "string", + "enum": [ + "anyone_with_link" + ] + }, + { + "description": "Only members of the owner's organization can use the URL.", + "type": "string", + "enum": [ + "organization_only" + ] + } + ] + }, "LengthUnit": { "type": "number", "format": "double" @@ -50415,6 +51811,48 @@ "updated_at" ] }, + "ProjectShareLinkResponse": { + "description": "Owner-visible share-link metadata for project downloads.", + "type": "object", + "properties": { + "access_mode": { + "description": "Access policy for the share link.", + "allOf": [ + { + "$ref": "#/components/schemas/KclProjectShareLinkAccessMode" + } + ] + }, + "created_at": { + "title": "DateTime", + "description": "Share-link creation timestamp.", + "type": "string", + "format": "date-time" + }, + "key": { + "description": "Opaque identifier used in the public shared URL.", + "type": "string" + }, + "updated_at": { + "title": "DateTime", + "description": "Share-link last update timestamp.", + "type": "string", + "format": "date-time" + }, + "url": { + "description": "Fully-qualified URL that can be shared.", + "type": "string", + "format": "uri" + } + }, + "required": [ + "access_mode", + "created_at", + "key", + "updated_at", + "url" + ] + }, "ProjectSummaryResponse": { "description": "Owner-visible project summary payload.", "type": "object", @@ -50511,6 +51949,20 @@ "email" ] }, + "PublicMailingListMembershipRequest": { + "description": "Request body for public mailing-list membership changes.", + "type": "object", + "properties": { + "email": { + "description": "Email address to add or remove.", + "type": "string", + "format": "email" + } + }, + "required": [ + "email" + ] + }, "PublicProjectOwnerResponse": { "description": "Public creator metadata for community project listings.", "type": "object", @@ -50547,6 +51999,11 @@ } ] }, + "like_count": { + "description": "Current total public like count for the project.", + "type": "integer", + "format": "int64" + }, "owner": { "description": "Public creator metadata.", "allOf": [ @@ -50575,11 +52032,31 @@ "categories", "description", "id", + "like_count", "owner", "published_at", "title" ] }, + "PublicProjectVoteResponse": { + "description": "Signed-in viewer vote state for a public project.", + "type": "object", + "properties": { + "like_count": { + "description": "Current total public like count for the project.", + "type": "integer", + "format": "int64" + }, + "liked": { + "description": "Whether the authenticated viewer currently likes the project.", + "type": "boolean" + } + }, + "required": [ + "like_count", + "liked" + ] + }, "RawFile": { "description": "A raw file with unencoded contents to be passed over binary websockets. When raw files come back for exports it is sent as binary/bson, not text/json.", "type": "object", @@ -55727,6 +57204,10 @@ "default": "", "type": "string", "format": "phone" + }, + "username": { + "description": "Public username/handle for community-facing features. Empty clears it.", + "type": "string" } }, "required": [ @@ -55901,7 +57382,8 @@ "enum": [ "aquarium", "proprietary_to_kcl_conversion_beta", - "new_sketch_mode" + "new_sketch_mode", + "classic_sketch_mode" ] }, "UserFeatureEntry": { @@ -57039,6 +58521,13 @@ "url": "https://zoo.dev/docs/api/payments" } }, + { + "name": "projects", + "description": "Operations for user-owned projects, public project discovery, publishing, voting, and share links.", + "externalDocs": { + "url": "https://zoo.dev/docs/api/projects" + } + }, { "name": "service-accounts", "description": "Service accounts allow organizations to call the API. Organization admins can create, delete, and list the service accounts for their org. Service accounts are scoped to an organization not individual users, these are better to use for automations than individual API tokens, since they won't stop working when an individual leaves the company.", diff --git a/src/cmd_kcl.rs b/src/cmd_kcl.rs index 8870702c..2a60fde1 100644 --- a/src/cmd_kcl.rs +++ b/src/cmd_kcl.rs @@ -1722,8 +1722,7 @@ fn get_modeling_settings_from_project_toml(input: &std::path::Path) -> Result Result<()> { + match &self.subcmd { + SubCommand::Categories(cmd) => cmd.run(ctx).await, + SubCommand::Delete(cmd) => cmd.run(ctx).await, + SubCommand::Download(cmd) => cmd.run(ctx).await, + SubCommand::List(cmd) => cmd.run(ctx).await, + SubCommand::Publish(cmd) => cmd.run(ctx).await, + SubCommand::View(cmd) => cmd.run(ctx).await, + SubCommand::Upload(cmd) => cmd.run(ctx).await, + } + } +} + +/// List the active project categories available for submission. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectCategories { + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectCategories { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let client = ctx.api_client("")?; + let categories = client.projects().list_categories().await?; + let categories = categories + .into_iter() + .map(project_category_output_row) + .collect::>(); + let format = ctx.format(&self.format)?; + ctx.io.write_output_for_vec(&format, categories)?; + Ok(()) + } +} + +/// Delete one of your uploaded projects. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectDelete { + /// The project id, or a local project directory, `.kcl` file, or `project.toml`. + /// + /// When a local path is provided, the persisted Zoo cloud project id will be removed from + /// `project.toml` after the remote project is deleted. + #[clap(name = "id-or-path", required = true)] + pub input: String, +} + +enum ProjectTarget { + Id(uuid::Uuid), + Local { + local: crate::project::LocalProject, + id: uuid::Uuid, + }, +} + +fn resolve_project_target(input: &str, environment: &str) -> Result { + let path = PathBuf::from(input); + if input == "." || path.exists() { + let local = crate::project::resolve_local_project(&path)?; + let id = crate::project::read_persisted_cloud_project_id(&local.project_toml, environment)? + .with_context(|| format!("no Zoo cloud project id found in `{}`", local.project_toml.display()))?; + return Ok(ProjectTarget::Local { local, id }); + } + + if let Ok(id) = uuid::Uuid::parse_str(input) { + return Ok(ProjectTarget::Id(id)); + } + + anyhow::bail!("input `{input}` must be an existing project path or a project id"); +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectDelete { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let environment = ctx.project_cloud_environment_name("")?; + let target = resolve_project_target(&self.input, &environment)?; + let id = match &target { + ProjectTarget::Id(id) => *id, + ProjectTarget::Local { id, .. } => *id, + }; + + let endpoint = format!("/user/projects/{id}"); + let resp = ctx + .raw_http_request("", reqwest::Method::DELETE, &endpoint)? + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("{} {}", status, body); + } + + if let ProjectTarget::Local { local, .. } = target { + crate::project::clear_persisted_cloud_project_id(&local.project_toml, &environment)?; + writeln!( + ctx.io.out, + "{} Deleted Zoo cloud project {} and cleared {}", + ctx.io.color_scheme().success_icon(), + id, + local.project_toml.display() + )?; + } else { + writeln!( + ctx.io.out, + "{} Deleted Zoo cloud project {}", + ctx.io.color_scheme().success_icon(), + id + )?; + } + + Ok(()) + } +} + +/// Download one of your projects into a local directory. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectDownload { + /// The project id. + #[clap(name = "id", required = true)] + pub id: uuid::Uuid, + + /// The directory to extract the project into. + #[clap(name = "output-dir", default_value = ".")] + pub output_dir: PathBuf, + + /// Allow extracting into a non-empty destination, overwriting existing files in place. + #[clap(long, default_value = "false")] + pub force: bool, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectDownload { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + crate::project::ensure_download_destination(&self.output_dir, self.force)?; + let environment = ctx.project_cloud_environment_name("")?; + + let client = ctx.api_client("")?; + let endpoint = format!("/user/projects/{}/download", self.id); + let req = client.request_raw(http::Method::GET, &endpoint, None).await?; + let resp = req.0.send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("{} {}", status, body); + } + + let body = resp.bytes().await?; + let mut archive = tar::Archive::new(std::io::Cursor::new(body)); + archive + .unpack(&self.output_dir) + .with_context(|| format!("failed to extract archive into `{}`", self.output_dir.display()))?; + + if let Some(project_root) = crate::project::find_project_root_under(&self.output_dir)? { + let project_toml = project_root.join("project.toml"); + crate::project::persist_cloud_project_id(&project_toml, &environment, self.id)?; + writeln!( + ctx.io.out, + "{} Downloaded project {} into {}", + ctx.io.color_scheme().success_icon(), + self.id, + project_root.display() + )?; + } else { + writeln!( + ctx.io.out, + "{} Downloaded project {} into {}", + ctx.io.color_scheme().success_icon(), + self.id, + self.output_dir.display() + )?; + writeln!( + ctx.io.out, + "Could not locate a project root to persist the project id automatically." + )?; + } + + Ok(()) + } +} + +/// List your projects. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectList { + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[derive(Debug, Clone, serde::Serialize, tabled::Tabled)] +struct ProjectCategoryOutputRow { + description: String, + display_name: String, + slug: String, +} + +#[derive(Debug, Clone, serde::Serialize, tabled::Tabled)] +struct ProjectListTableRow { + title: String, + description: String, + id: uuid::Uuid, + #[tabled(rename = "updated")] + updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone, serde::Serialize, tabled::Tabled)] +struct ProjectViewTableRow { + title: String, + description: String, + id: uuid::Uuid, + #[tabled(rename = "publication")] + publication_status: kittycad::types::KclProjectPublicationStatus, + #[tabled(rename = "files")] + file_count: usize, + #[tabled(rename = "created")] + created_at: chrono::DateTime, + #[tabled(rename = "updated")] + updated_at: chrono::DateTime, +} + +fn project_category_output_row(category: kittycad::types::ProjectCategoryResponse) -> ProjectCategoryOutputRow { + ProjectCategoryOutputRow { + description: category.description, + display_name: category.display_name, + slug: category.slug, + } +} + +fn project_view_table_row(project: &kittycad::types::ProjectResponse) -> ProjectViewTableRow { + ProjectViewTableRow { + title: project.title.clone(), + description: project.description.clone(), + id: project.id, + publication_status: project.publication_status.clone(), + file_count: project.files.len(), + created_at: project.created_at, + updated_at: project.updated_at, + } +} + +fn write_project_output( + ctx: &mut crate::context::Context<'_>, + format: &FormatOutput, + project: &kittycad::types::ProjectResponse, +) -> Result<()> { + match format { + FormatOutput::Json => ctx.io.write_output_json(&serde_json::to_value(project)?)?, + FormatOutput::Yaml => ctx.io.write_output_yaml(project)?, + FormatOutput::Table => ctx + .io + .write_output_for_vec(format, vec![project_view_table_row(project)])?, + } + + Ok(()) +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectList { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let client = ctx.api_client("")?; + let projects = client.projects().list().await?; + let format = ctx.format(&self.format)?; + match format { + FormatOutput::Json => ctx.io.write_output_json(&serde_json::to_value(&projects)?)?, + FormatOutput::Yaml => ctx.io.write_output_yaml(&projects)?, + FormatOutput::Table => { + let rows = projects + .into_iter() + .map(|project| ProjectListTableRow { + title: project.title, + description: project.description, + id: project.id, + updated_at: project.updated_at, + }) + .collect::>(); + ctx.io.write_output_for_vec(&format, rows)? + } + } + Ok(()) + } +} + +/// View one of your projects. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectView { + /// The project id, or a local project directory, `.kcl` file, or `project.toml`. + #[clap(name = "id-or-path", required = true)] + pub input: String, + + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectView { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let environment = ctx.project_cloud_environment_name("")?; + let target = resolve_project_target(&self.input, &environment)?; + let project_id = match target { + ProjectTarget::Id(id) => id, + ProjectTarget::Local { id, .. } => id, + }; + let client = ctx.api_client("")?; + let project = client.projects().get(project_id).await?; + let format = ctx.format(&self.format)?; + write_project_output(ctx, &format, &project)?; + Ok(()) + } +} + +/// Submit an existing cloud project for publication review. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectPublish { + /// The project id, or a local project directory, `.kcl` file, or `project.toml`. + #[clap(name = "id-or-path", required = true)] + pub input: String, + + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectPublish { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let environment = ctx.project_cloud_environment_name("")?; + let target = resolve_project_target(&self.input, &environment)?; + let project_id = match &target { + ProjectTarget::Id(id) => *id, + ProjectTarget::Local { id, .. } => *id, + }; + + let client = ctx.api_client("")?; + let project = client.projects().publish(project_id).await?; + + if let ProjectTarget::Local { local, .. } = target { + crate::project::persist_cloud_project_id(&local.project_toml, &environment, project.id)?; + } + writeln!( + ctx.io.out, + "{} Submitted Zoo cloud project {} for publication review", + ctx.io.color_scheme().success_icon(), + project.id + )?; + + let format = ctx.format(&self.format)?; + write_project_output(ctx, &format, &project)?; + Ok(()) + } +} + +/// Upload a local project. +/// +/// If the local `project.toml` already contains a Zoo cloud project id, this +/// will update that project unless `--new` is passed. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectUpload { + /// The project directory, a `.kcl` file within it, or `project.toml`. + #[clap(name = "input", default_value = ".")] + pub input: PathBuf, + + /// Always create a new remote project even if one is already persisted locally. + #[clap(long, default_value = "false", conflicts_with = "id")] + pub new: bool, + + /// Override the persisted Zoo cloud project id from `project.toml`. + #[clap(long, conflicts_with = "new")] + pub id: Option, + + /// Title to use for the cloud project. Defaults to the local project directory name. + #[clap(long)] + pub title: Option, + + /// Description to use for the cloud project. Defaults to the existing remote description when updating. + #[clap(long)] + pub description: Option, + + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectUpload { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let local = crate::project::resolve_local_project(&self.input)?; + let environment = ctx.project_cloud_environment_name("")?; + let existing_id = match self.id { + Some(id) => Some(id), + None if self.new => None, + None => crate::project::read_persisted_cloud_project_id(&local.project_toml, &environment)?, + }; + let attachments = crate::project::collect_project_attachments(&local.root)?; + let client = ctx.api_client("")?; + + let project = if let Some(id) = existing_id { + let existing = client.projects().get(id).await?; + let body = ProjectUpsertBody { + title: self.title.clone().unwrap_or(existing.title), + description: self.description.clone().unwrap_or(existing.description), + }; + update_project_with_body(ctx, attachments, id, &body).await? + } else { + let body = ProjectUpsertBody { + title: self.title.clone().unwrap_or_else(|| default_project_title(&local.root)), + description: self.description.clone().unwrap_or_default(), + }; + create_project_with_body(ctx, attachments, &body).await? + }; + + crate::project::persist_cloud_project_id(&local.project_toml, &environment, project.id)?; + writeln!( + ctx.io.out, + "{} {} Zoo cloud project id {} in {}", + ctx.io.color_scheme().success_icon(), + if existing_id.is_some() { "Updated" } else { "Stored" }, + project.id, + local.project_toml.display() + )?; + + let format = ctx.format(&self.format)?; + write_project_output(ctx, &format, &project)?; + Ok(()) + } +} + +#[derive(Debug, Clone, serde::Serialize)] +struct ProjectUpsertBody { + title: String, + description: String, +} + +fn default_project_title(root: &std::path::Path) -> String { + root.file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("project") + .to_string() +} + +fn build_project_form( + attachments: Vec, + body: &ProjectUpsertBody, +) -> Result { + use std::convert::TryInto; + + let mut form = reqwest::multipart::Form::new(); + let mut json_part = reqwest::multipart::Part::text(serde_json::to_string(body)?); + json_part = json_part.file_name("body.json"); + json_part = json_part.mime_str("application/json")?; + form = form.part("body", json_part); + + for attachment in attachments { + form = form.part(attachment.name.clone(), attachment.try_into()?); + } + + Ok(form) +} + +async fn create_project_with_body( + ctx: &crate::context::Context<'_>, + attachments: Vec, + body: &ProjectUpsertBody, +) -> Result { + let req = ctx.raw_http_request("", reqwest::Method::POST, "/user/projects")?; + send_project_form(req, attachments, body).await +} + +async fn update_project_with_body( + ctx: &crate::context::Context<'_>, + attachments: Vec, + id: uuid::Uuid, + body: &ProjectUpsertBody, +) -> Result { + let endpoint = format!("/user/projects/{id}"); + let req = ctx.raw_http_request("", reqwest::Method::PUT, &endpoint)?; + send_project_form(req, attachments, body).await +} + +async fn send_project_form( + req: reqwest::RequestBuilder, + attachments: Vec, + body: &ProjectUpsertBody, +) -> Result { + let form = build_project_form(attachments, body)?; + let resp = req.multipart(form).send().await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + + if !status.is_success() { + anyhow::bail!("{} {}", status, text); + } + + serde_json::from_str(&text).with_context(|| format!("failed to parse project response body: {text}")) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn resolve_project_target_accepts_uuid() { + let id = uuid::Uuid::new_v4(); + + let target = resolve_project_target(&id.to_string(), "zoo.dev").expect("resolve project target"); + + match target { + ProjectTarget::Id(got) => assert_eq!(got, id), + ProjectTarget::Local { .. } => panic!("expected uuid target"), + } + } + + #[test] + fn resolve_project_target_accepts_project_path() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + let project_toml = tmp.path().join("project.toml"); + let id = uuid::Uuid::new_v4(); + crate::project::persist_cloud_project_id(&project_toml, "zoo.dev", id).expect("persist cloud project id"); + + let target = + resolve_project_target(tmp.path().to_str().expect("path utf8"), "zoo.dev").expect("resolve project target"); + + match target { + ProjectTarget::Local { local, id: got } => { + assert_eq!(got, id); + assert_eq!(local.root, PathBuf::from(tmp.path())); + assert_eq!(local.project_toml, project_toml); + } + ProjectTarget::Id(_) => panic!("expected local target"), + } + } +} diff --git a/src/cmd_user.rs b/src/cmd_user.rs index b0cf8c00..f9dd78dd 100644 --- a/src/cmd_user.rs +++ b/src/cmd_user.rs @@ -92,6 +92,7 @@ mod test { new_is_onboarded: Default::default(), new_company: Default::default(), new_discord: Default::default(), + new_username: Default::default(), new_phone: Default::default(), new_last_name: Default::default(), new_first_name: Default::default(), diff --git a/src/context.rs b/src/context.rs index 0b45bf9c..be5d32ae 100644 --- a/src/context.rs +++ b/src/context.rs @@ -19,6 +19,34 @@ pub struct Context<'a> { } impl Context<'_> { + fn resolve_api_host_and_baseurl(&self, hostname: &str) -> Result<(String, String)> { + let host = if !hostname.is_empty() { + hostname.to_string() + } else if let Some(h) = &self.override_host { + h.clone() + } else { + self.config.default_host()? + }; + + let mut baseurl = host.to_string(); + if !host.starts_with("http://") && !host.starts_with("https://") { + baseurl = format!("https://{host}"); + if host.starts_with("localhost") { + baseurl = format!("http://{host}") + } + } + + Ok((host, baseurl)) + } + + fn http_client_builder(&self) -> reqwest::ClientBuilder { + let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),); + reqwest::Client::builder() + .user_agent(user_agent) + .timeout(std::time::Duration::from_secs(600)) + .connect_timeout(std::time::Duration::from_secs(60)) + } + pub fn new(config: &mut (dyn Config + Send + Sync)) -> Context<'_> { // Let's get our IO streams. let mut io = crate::iostreams::IoStreams::system(); @@ -60,35 +88,12 @@ impl Context<'_> { /// This function returns an API client for Zoo that is based on the configured /// user. pub fn api_client(&self, hostname: &str) -> Result { - // Resolution order: explicit arg > global override > default host from config - let host = if !hostname.is_empty() { - hostname.to_string() - } else if let Some(h) = &self.override_host { - h.clone() - } else { - self.config.default_host()? - }; - - // Change the baseURL to the one we want. - let mut baseurl = host.to_string(); - if !host.starts_with("http://") && !host.starts_with("https://") { - baseurl = format!("https://{host}"); - if host.starts_with("localhost") { - baseurl = format!("http://{host}") - } - } + let (host, baseurl) = self.resolve_api_host_and_baseurl(hostname)?; - let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),); - let http_client = reqwest::Client::builder() - .user_agent(user_agent) - // For file conversions we need this to be long. - .timeout(std::time::Duration::from_secs(600)) - .connect_timeout(std::time::Duration::from_secs(60)); - let ws_client = reqwest::Client::builder() - .user_agent(user_agent) + let http_client = self.http_client_builder(); + let ws_client = self + .http_client_builder() // For file conversions we need this to be long. - .timeout(std::time::Duration::from_secs(600)) - .connect_timeout(std::time::Duration::from_secs(60)) .tcp_keepalive(std::time::Duration::from_secs(600)) .http1_only(); @@ -105,11 +110,37 @@ impl Context<'_> { Ok(client) } + pub fn raw_http_request( + &self, + hostname: &str, + method: reqwest::Method, + uri: &str, + ) -> Result { + let (host, baseurl) = self.resolve_api_host_and_baseurl(hostname)?; + let token = self.config.get(&host, "token")?; + let client = self.http_client_builder().build()?; + let url = if uri.starts_with("https://") || uri.starts_with("http://") { + uri.to_string() + } else { + format!("{}/{}", baseurl.trim_end_matches('/'), uri.trim_start_matches('/')) + }; + + Ok(client.request(method, url).bearer_auth(token).header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + )) + } + /// Return the global host override if set. pub fn global_host(&self) -> Option<&str> { self.override_host.as_deref() } + pub fn project_cloud_environment_name(&self, hostname: &str) -> Result { + let (_, baseurl) = self.resolve_api_host_and_baseurl(hostname)?; + crate::project::project_cloud_environment_name_for_host(&baseurl) + } + // Test-only helper for verifying host resolution semantics without creating a client. #[cfg(test)] pub(crate) fn resolve_host_for_tests(&self, hostname: &str) -> Result { diff --git a/src/main.rs b/src/main.rs index c56f21bf..a8c28949 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,8 @@ pub mod cmd_kcl; pub mod cmd_ml; /// The open command. pub mod cmd_open; +/// The project command. +pub mod cmd_project; /// The say command. pub mod cmd_say; /// The start-session command. @@ -61,6 +63,7 @@ mod context; mod docs_markdown; mod iostreams; mod ml; +mod project; mod types; #[cfg(test)] @@ -150,6 +153,7 @@ enum SubCommand { Generate(cmd_generate::CmdGenerate), Kcl(cmd_kcl::CmdKcl), Ml(cmd_ml::CmdMl), + Project(cmd_project::CmdProject), Say(cmd_say::CmdSay), // Hide until is done. #[clap(hide = true)] @@ -284,6 +288,7 @@ async fn do_main(mut args: Vec, ctx: &mut crate::context::Context<'_>) - SubCommand::Generate(cmd) => run_cmd(&cmd, ctx).await, SubCommand::Kcl(cmd) => run_cmd(&cmd, ctx).await, SubCommand::Ml(cmd) => run_cmd(&cmd, ctx).await, + SubCommand::Project(cmd) => run_cmd(&cmd, ctx).await, SubCommand::Say(cmd) => run_cmd(&cmd, ctx).await, SubCommand::StartSession(cmd) => run_cmd(&cmd, ctx).await, SubCommand::Open(cmd) => run_cmd(&cmd, ctx).await, diff --git a/src/ml/copilot/run.rs b/src/ml/copilot/run.rs index db4ffe3c..930d204d 100644 --- a/src/ml/copilot/run.rs +++ b/src/ml/copilot/run.rs @@ -681,8 +681,7 @@ fn get_modeling_settings_from_project_toml(input: &std::path::Path) -> anyhow::R input.parent().unwrap().to_path_buf() }; if let Some(p) = crate::cmd_kcl::find_project_toml(&dir)? { - let s = std::fs::read_to_string(&p)?; - let project: kcl_lib::ProjectConfiguration = toml::from_str(&s)?; + let project = crate::project::read_project_configuration(&p)?; let mut derived: kcl_lib::ExecutorSettings = project.into(); let typed = TypedPath::from(input.display().to_string().as_str()); derived.with_current_file(typed); diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 00000000..ce145ada --- /dev/null +++ b/src/project.rs @@ -0,0 +1,484 @@ +use std::{ + collections::VecDeque, + path::{Path, PathBuf}, +}; + +use anyhow::{Context as _, Result}; + +pub struct LocalProject { + pub root: PathBuf, + pub project_toml: PathBuf, +} + +pub fn resolve_local_project(input: &Path) -> Result { + let input = normalize_input_path(input)?; + + let root = if input.is_dir() { + if let Some(project_toml) = crate::cmd_kcl::find_project_toml(&input)? { + project_toml + .parent() + .context("project.toml is missing a parent directory")? + .to_path_buf() + } else if input.join("main.kcl").exists() { + input + } else { + anyhow::bail!( + "directory `{}` does not contain a main.kcl file or a project.toml file", + input.display() + ); + } + } else if input.file_name().and_then(|name| name.to_str()) == Some("project.toml") { + input + .parent() + .context("project.toml is missing a parent directory")? + .to_path_buf() + } else if input.extension().and_then(|ext| ext.to_str()) == Some("kcl") { + if let Some(parent) = input.parent() { + if let Some(project_toml) = crate::cmd_kcl::find_project_toml(parent)? { + project_toml + .parent() + .context("project.toml is missing a parent directory")? + .to_path_buf() + } else { + parent.to_path_buf() + } + } else { + anyhow::bail!("could not determine project root from `{}`", input.display()); + } + } else { + anyhow::bail!( + "input `{}` must be a directory, a `.kcl` file, or a `project.toml` file", + input.display() + ); + }; + + if !root.join("main.kcl").exists() { + anyhow::bail!("project root `{}` does not contain a main.kcl file", root.display()); + } + + let project_toml = ensure_project_toml(&root)?; + + Ok(LocalProject { root, project_toml }) +} + +pub fn read_persisted_cloud_project_id(project_toml: &Path, environment: &str) -> Result> { + if !project_toml.exists() { + return Ok(None); + } + + let config = read_project_configuration(project_toml)?; + let project_id = config + .cloud + .environments + .get(environment) + .map(|settings| settings.project_id); + + if let Some(project_id) = project_id { + return if project_id.is_nil() { + Ok(None) + } else { + Ok(Some(project_id)) + }; + } + + Ok(None) +} + +pub fn persist_cloud_project_id(project_toml: &Path, environment: &str, id: uuid::Uuid) -> Result<()> { + let mut config = read_or_default_project_configuration(project_toml)?; + config + .cloud + .environments + .entry(environment.to_owned()) + .or_default() + .project_id = id; + write_project_configuration(project_toml, &config) +} + +pub fn clear_persisted_cloud_project_id(project_toml: &Path, environment: &str) -> Result<()> { + let mut config = read_or_default_project_configuration(project_toml)?; + config.cloud.environments.shift_remove(environment); + write_project_configuration(project_toml, &config) +} + +pub fn collect_project_attachments(root: &Path) -> Result> { + let gitignore = project_gitignore(root)?; + let mut dirs = VecDeque::from([root.to_path_buf()]); + let mut files = Vec::new(); + + while let Some(dir) = dirs.pop_front() { + for entry in std::fs::read_dir(&dir).with_context(|| format!("failed to read `{}`", dir.display()))? { + let entry = entry.with_context(|| format!("failed to inspect entry in `{}`", dir.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("failed to inspect `{}`", entry.path().display()))?; + + if file_type.is_symlink() { + continue; + } + + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + + if file_type.is_dir() { + if should_skip_dir(&name) || is_ignored_by_project_gitignore(gitignore.as_ref(), &path, true) { + continue; + } + dirs.push_back(path); + continue; + } + + if file_type.is_file() && !is_ignored_by_project_gitignore(gitignore.as_ref(), &path, false) { + files.push(path); + } + } + } + + files.sort(); + + files.into_iter().map(|path| build_attachment(root, &path)).collect() +} + +pub fn find_project_root_under(base: &Path) -> Result> { + let mut dirs = VecDeque::from([base.to_path_buf()]); + let mut matches = Vec::new(); + + while let Some(dir) = dirs.pop_front() { + if dir.join("main.kcl").exists() { + matches.push(dir.clone()); + } + + for entry in std::fs::read_dir(&dir).with_context(|| format!("failed to read `{}`", dir.display()))? { + let entry = entry.with_context(|| format!("failed to inspect entry in `{}`", dir.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("failed to inspect `{}`", entry.path().display()))?; + if file_type.is_dir() && !file_type.is_symlink() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if should_skip_dir(&name) { + continue; + } + dirs.push_back(entry.path()); + } + } + } + + matches.sort(); + Ok(matches.into_iter().next()) +} + +pub fn ensure_download_destination(output_dir: &Path, force: bool) -> Result<()> { + if output_dir.exists() { + let metadata = + std::fs::metadata(output_dir).with_context(|| format!("failed to inspect `{}`", output_dir.display()))?; + if !metadata.is_dir() { + anyhow::bail!("download destination `{}` is not a directory", output_dir.display()); + } + + let mut entries = + std::fs::read_dir(output_dir).with_context(|| format!("failed to read `{}`", output_dir.display()))?; + if !force && entries.next().transpose()?.is_some() { + anyhow::bail!( + "download destination `{}` is not empty; pass `--force` to overwrite existing files", + output_dir.display() + ); + } + } else { + std::fs::create_dir_all(output_dir).with_context(|| format!("failed to create `{}`", output_dir.display()))?; + } + + Ok(()) +} + +fn project_gitignore(root: &Path) -> Result> { + let gitignore_path = root.join(".gitignore"); + if !gitignore_path.is_file() { + return Ok(None); + } + + let mut builder = ignore::gitignore::GitignoreBuilder::new(root); + builder.add(gitignore_path); + let gitignore = builder + .build() + .with_context(|| format!("failed to parse `{}`", root.join(".gitignore").display()))?; + Ok(Some(gitignore)) +} + +pub fn read_project_configuration(project_toml: &Path) -> Result { + let contents = std::fs::read_to_string(project_toml) + .with_context(|| format!("failed to read `{}`", project_toml.display()))?; + kcl_lib::ProjectConfiguration::parse_and_validate(&contents) + .with_context(|| format!("failed to parse `{}`", project_toml.display())) +} + +fn read_or_default_project_configuration(project_toml: &Path) -> Result { + if project_toml.exists() { + read_project_configuration(project_toml) + } else { + Ok(kcl_lib::ProjectConfiguration::default()) + } +} + +fn write_project_configuration(project_toml: &Path, config: &kcl_lib::ProjectConfiguration) -> Result<()> { + let contents = toml::to_string(config)?; + std::fs::write(project_toml, contents).with_context(|| format!("failed to write `{}`", project_toml.display()))?; + Ok(()) +} + +fn is_ignored_by_project_gitignore( + gitignore: Option<&ignore::gitignore::Gitignore>, + path: &Path, + is_dir: bool, +) -> bool { + gitignore + .map(|gitignore| gitignore.matched_path_or_any_parents(path, is_dir).is_ignore()) + .unwrap_or(false) +} + +fn build_attachment(root: &Path, path: &Path) -> Result { + let mut attachment = kittycad::types::multipart::Attachment::try_from(path.to_path_buf()) + .with_context(|| format!("failed to read `{}`", path.display()))?; + let relative = path + .strip_prefix(root) + .with_context(|| format!("failed to strip `{}` from `{}`", root.display(), path.display()))?; + + let relative = relative.to_path_buf(); + attachment.name = relative.to_string_lossy().to_string(); + attachment.filepath = Some(relative); + Ok(attachment) +} + +fn ensure_project_toml(root: &Path) -> Result { + let path = root.join("project.toml"); + if path.exists() { + return Ok(path); + } + + let contents = toml::to_string(&kcl_lib::ProjectConfiguration::default())?; + std::fs::write(&path, contents).with_context(|| format!("failed to create `{}`", path.display()))?; + Ok(path) +} + +fn normalize_input_path(input: &Path) -> Result { + if input == Path::new(".") { + Ok(std::env::current_dir()?) + } else { + Ok(input.to_path_buf()) + } +} + +pub fn project_cloud_environment_name_for_host(host: &str) -> Result { + let parsed = crate::cmd_auth::parse_host(host)?; + let hostname = parsed + .host_str() + .with_context(|| format!("host `{host}` is missing a hostname"))?; + + let mut environment = hostname.strip_prefix("api.").unwrap_or(hostname).to_string(); + if let Some(port) = parsed.port() { + environment.push(':'); + environment.push_str(&port.to_string()); + } + + Ok(environment) +} + +fn should_skip_dir(name: &str) -> bool { + matches!(name, ".git" | ".jj" | "target" | "node_modules") +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEFAULT_ENVIRONMENT: &str = "zoo.dev"; + + #[test] + fn persist_cloud_project_id_round_trip() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + + let project = resolve_local_project(tmp.path()).expect("resolve project"); + let id = uuid::Uuid::new_v4(); + persist_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT, id).expect("persist cloud project id"); + + let got = + read_persisted_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT).expect("read cloud project id"); + assert_eq!(got, Some(id)); + } + + #[test] + fn persist_cloud_project_id_does_not_overwrite_local_project_id() { + let tmp = tempfile::tempdir().expect("tempdir"); + let local_id = uuid::Uuid::new_v4(); + let cloud_id = uuid::Uuid::new_v4(); + std::fs::write( + tmp.path().join("project.toml"), + format!( + "[settings.meta]\nid = \"{local_id}\"\n\n[settings.app]\n\n[settings.modeling]\n\n[settings.text_editor]\n\n[settings.command_bar]\n" + ), + ) + .expect("write project.toml"); + + persist_cloud_project_id(&tmp.path().join("project.toml"), DEFAULT_ENVIRONMENT, cloud_id) + .expect("persist cloud project id"); + + let contents = std::fs::read_to_string(tmp.path().join("project.toml")).expect("read project.toml"); + let parsed: kcl_lib::ProjectConfiguration = toml::from_str(&contents).expect("parse project config"); + assert_eq!(parsed.settings.meta.id, local_id); + assert_eq!( + parsed + .cloud + .environments + .get(DEFAULT_ENVIRONMENT) + .expect("cloud environment") + .project_id, + cloud_id + ); + + let got = read_persisted_cloud_project_id(&tmp.path().join("project.toml"), DEFAULT_ENVIRONMENT) + .expect("read cloud project id"); + assert_eq!(got, Some(cloud_id)); + } + + #[test] + fn clear_persisted_cloud_project_id_round_trip() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + + let project = resolve_local_project(tmp.path()).expect("resolve project"); + persist_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT, uuid::Uuid::new_v4()) + .expect("persist cloud project id"); + + clear_persisted_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT).expect("clear cloud project id"); + + let got = + read_persisted_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT).expect("read cloud project id"); + assert_eq!(got, None); + } + + #[test] + fn clear_persisted_cloud_project_id_only_clears_requested_environment() { + let tmp = tempfile::tempdir().expect("tempdir"); + let zoo_id = uuid::Uuid::new_v4(); + let dev_id = uuid::Uuid::new_v4(); + std::fs::write( + tmp.path().join("project.toml"), + format!( + "[cloud.\"zoo.dev\"]\nproject_id = \"{zoo_id}\"\n\n[cloud.\"dev.zoo.dev\"]\nproject_id = \"{dev_id}\"\n" + ), + ) + .expect("write project.toml"); + + clear_persisted_cloud_project_id(&tmp.path().join("project.toml"), DEFAULT_ENVIRONMENT) + .expect("clear cloud project id"); + + assert_eq!( + read_persisted_cloud_project_id(&tmp.path().join("project.toml"), DEFAULT_ENVIRONMENT) + .expect("read zoo cloud project id"), + None + ); + assert_eq!( + read_persisted_cloud_project_id(&tmp.path().join("project.toml"), "dev.zoo.dev") + .expect("read dev cloud project id"), + Some(dev_id) + ); + } + + #[test] + fn project_cloud_environment_name_for_host_strips_api_prefix() { + assert_eq!( + project_cloud_environment_name_for_host("https://api.zoo.dev").expect("default environment"), + "zoo.dev" + ); + assert_eq!( + project_cloud_environment_name_for_host("https://api.dev.zoo.dev").expect("dev environment"), + "dev.zoo.dev" + ); + assert_eq!( + project_cloud_environment_name_for_host("http://localhost:8888").expect("localhost environment"), + "localhost:8888" + ); + } + + #[test] + fn collect_project_attachments_uses_relative_paths() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("subdir")).expect("mkdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + std::fs::write(tmp.path().join("subdir/part.kcl"), "cube(2)\n").expect("write part"); + + let project = resolve_local_project(tmp.path()).expect("resolve project"); + let attachments = collect_project_attachments(&project.root).expect("collect attachments"); + + let mut paths = attachments + .iter() + .filter_map(|attachment| attachment.filepath.as_ref()) + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + paths.sort(); + + assert_eq!(paths, vec!["main.kcl", "project.toml", "subdir/part.kcl"]); + } + + #[test] + fn collect_project_attachments_respects_root_gitignore() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("ignored-dir")).expect("mkdir ignored-dir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + std::fs::write(tmp.path().join(".gitignore"), "ignored.kcl\nignored-dir/\n").expect("write gitignore"); + std::fs::write(tmp.path().join("ignored.kcl"), "cube(2)\n").expect("write ignored"); + std::fs::write(tmp.path().join("ignored-dir/part.kcl"), "cube(3)\n").expect("write ignored dir file"); + std::fs::write(tmp.path().join("kept.kcl"), "cube(4)\n").expect("write kept"); + + let project = resolve_local_project(tmp.path()).expect("resolve project"); + let attachments = collect_project_attachments(&project.root).expect("collect attachments"); + + let mut paths = attachments + .iter() + .filter_map(|attachment| attachment.filepath.as_ref()) + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + paths.sort(); + + assert_eq!(paths, vec![".gitignore", "kept.kcl", "main.kcl", "project.toml"]); + } + + #[test] + fn find_project_root_under_prefers_the_project_directory() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("downloaded-project/subdir")).expect("mkdir"); + std::fs::write(tmp.path().join("downloaded-project/main.kcl"), "cube(1)\n").expect("write main"); + + let found = find_project_root_under(tmp.path()).expect("find project root"); + assert_eq!(found, Some(tmp.path().join("downloaded-project"))); + } + + #[test] + fn ensure_download_destination_rejects_non_empty_dir_without_force() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + + let err = ensure_download_destination(tmp.path(), false).expect_err("should reject non-empty dir"); + assert!(err.to_string().contains("pass `--force`"), "unexpected error: {err:#}"); + } + + #[test] + fn ensure_download_destination_allows_non_empty_dir_with_force() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + + ensure_download_destination(tmp.path(), true).expect("should allow non-empty dir with force"); + } + + #[test] + fn ensure_download_destination_creates_missing_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let output_dir = tmp.path().join("downloaded-project"); + + ensure_download_destination(&output_dir, false).expect("create missing output dir"); + + assert!(output_dir.is_dir()); + } +}