← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ruinedyourlife/launchpad:add-webservice-for-craft-recipes into launchpad:master

 

Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:add-webservice-for-craft-recipes into launchpad:master with ~ruinedyourlife/launchpad:completing-craft-api as a prerequisite.

Commit message:
Add webservice for craft recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/474292
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:add-webservice-for-craft-recipes into launchpad:master.
diff --git a/lib/lp/app/browser/launchpad.py b/lib/lp/app/browser/launchpad.py
index 04aa196..64459ec 100644
--- a/lib/lp/app/browser/launchpad.py
+++ b/lib/lp/app/browser/launchpad.py
@@ -81,6 +81,7 @@ from lp.code.interfaces.codehosting import IBazaarApplication
 from lp.code.interfaces.codeimport import ICodeImportSet
 from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.interfaces.gitrepository import IGitRepositorySet
+from lp.crafts.interfaces.craftrecipe import ICraftRecipeSet
 from lp.layers import WebServiceLayer
 from lp.registry.enums import VCSType
 from lp.registry.interfaces.announcement import IAnnouncementSet
@@ -885,6 +886,7 @@ class LaunchpadRootNavigation(Navigation):
         "+code-imports": ICodeImportSet,
         "codeofconduct": ICodeOfConductSet,
         "+countries": ICountrySet,
+        "+craft-recipes": ICraftRecipeSet,
         "distros": IDistributionSet,
         "+git": IGitRepositorySet,
         "karmaaction": IKarmaActionSet,
diff --git a/lib/lp/crafts/browser/configure.zcml b/lib/lp/crafts/browser/configure.zcml
index d5a86e2..a93fa23 100644
--- a/lib/lp/crafts/browser/configure.zcml
+++ b/lib/lp/crafts/browser/configure.zcml
@@ -16,6 +16,10 @@
             module="lp.crafts.browser.craftrecipe"
             classes="CraftRecipeNavigation" />
         <lp:url
+            for="lp.crafts.interfaces.craftrecipe.ICraftRecipeSet"
+            path_expression="string:+craft-recipes"
+            parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
+        <lp:url
             for="lp.crafts.interfaces.craftrecipe.ICraftRecipeBuildRequest"
             path_expression="string:+build-request/${id}"
             attribute_to_parent="recipe" />
diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml
index 10fd401..0ec2393 100644
--- a/lib/lp/crafts/configure.zcml
+++ b/lib/lp/crafts/configure.zcml
@@ -101,4 +101,5 @@
         <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeJob" />
         <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJob" />
     </class>
+    <webservice:register module="lp.crafts.interfaces.webservice" />
 </configure>
diff --git a/lib/lp/crafts/interfaces/craftrecipe.py b/lib/lp/crafts/interfaces/craftrecipe.py
index 1fa3858..e67794a 100644
--- a/lib/lp/crafts/interfaces/craftrecipe.py
+++ b/lib/lp/crafts/interfaces/craftrecipe.py
@@ -21,6 +21,7 @@ __all__ = [
     "ICraftRecipe",
     "ICraftRecipeBuildRequest",
     "ICraftRecipeSet",
+    "ICraftRecipeView",
     "MissingSourcecraftYaml",
     "NoSourceForCraftRecipe",
     "NoSuchCraftRecipe",
@@ -29,8 +30,24 @@ __all__ = [
 import http.client
 
 from lazr.enum import EnumeratedType, Item
-from lazr.restful.declarations import error_status, exported
+from lazr.lifecycle.snapshot import doNotSnapshot
+from lazr.restful.declarations import (
+    REQUEST_USER,
+    call_with,
+    collection_default_content,
+    error_status,
+    export_destructor_operation,
+    export_factory_operation,
+    export_read_operation,
+    exported,
+    exported_as_webservice_collection,
+    exported_as_webservice_entry,
+    operation_for_version,
+    operation_parameters,
+    operation_returns_entry,
+)
 from lazr.restful.fields import CollectionField, Reference, ReferenceChoice
+from lazr.restful.interface import copy_field
 from zope.interface import Attribute, Interface
 from zope.schema import (
     Bool,
@@ -199,40 +216,53 @@ class CraftRecipeBuildRequestStatus(EnumeratedType):
     )
 
 
+# XXX ruinedyourlife 2024-10-02
+# https://bugs.launchpad.net/lazr.restful/+bug/760849:
+# "beta" is a lie to get WADL generation working.
+# Individual attributes must set their version to "devel".
+@exported_as_webservice_entry(as_of="beta")
 class ICraftRecipeBuildRequest(Interface):
     """A request to build a craft recipe."""
 
     id = Int(title=_("ID"), required=True, readonly=True)
 
-    date_requested = Datetime(
-        title=_("The time when this request was made"),
-        required=True,
-        readonly=True,
+    date_requested = exported(
+        Datetime(
+            title=_("The time when this request was made"),
+            required=True,
+            readonly=True,
+        )
     )
 
-    date_finished = Datetime(
-        title=_("The time when this request finished"),
-        required=False,
-        readonly=True,
+    date_finished = exported(
+        Datetime(
+            title=_("The time when this request finished"),
+            required=False,
+            readonly=True,
+        )
     )
 
-    recipe = Reference(
-        # Really ICraftRecipe.
-        Interface,
-        title=_("Craft recipe"),
-        required=True,
-        readonly=True,
+    recipe = exported(
+        Reference(
+            # Really ICraftRecipe.
+            Interface,
+            title=_("Craft recipe"),
+            required=True,
+            readonly=True,
+        )
     )
 
-    status = Choice(
-        title=_("Status"),
-        vocabulary=CraftRecipeBuildRequestStatus,
-        required=True,
-        readonly=True,
+    status = exported(
+        Choice(
+            title=_("Status"),
+            vocabulary=CraftRecipeBuildRequestStatus,
+            required=True,
+            readonly=True,
+        )
     )
 
-    error_message = TextLine(
-        title=_("Error message"), required=True, readonly=True
+    error_message = exported(
+        TextLine(title=_("Error message"), required=True, readonly=True)
     )
 
     channels = Dict(
@@ -249,12 +279,14 @@ class ICraftRecipeBuildRequest(Interface):
         readonly=True,
     )
 
-    builds = CollectionField(
-        title=_("Builds produced by this request"),
-        # Really ICraftRecipeBuild.
-        value_type=Reference(schema=Interface),
-        required=True,
-        readonly=True,
+    builds = exported(
+        CollectionField(
+            title=_("Builds produced by this request"),
+            # Really ICraftRecipeBuild.
+            value_type=Reference(schema=Interface),
+            required=True,
+            readonly=True,
+        )
     )
 
     requester = Reference(
@@ -270,28 +302,44 @@ class ICraftRecipeView(Interface):
 
     id = Int(title=_("ID"), required=True, readonly=True)
 
-    date_created = Datetime(
-        title=_("Date created"), required=True, readonly=True
+    date_created = exported(
+        Datetime(title=_("Date created"), required=True, readonly=True)
     )
-    date_last_modified = Datetime(
-        title=_("Date last modified"), required=True, readonly=True
+    date_last_modified = exported(
+        Datetime(title=_("Date last modified"), required=True, readonly=True)
     )
 
-    registrant = PublicPersonChoice(
-        title=_("Registrant"),
-        required=True,
-        readonly=True,
-        vocabulary="ValidPersonOrTeam",
-        description=_("The person who registered this craft recipe."),
+    registrant = exported(
+        PublicPersonChoice(
+            title=_("Registrant"),
+            required=True,
+            readonly=True,
+            vocabulary="ValidPersonOrTeam",
+            description=_("The person who registered this craft recipe."),
+        )
     )
 
     source = Attribute("The source branch for this craft recipe.")
 
-    private = Bool(
-        title=_("Private"),
-        required=False,
-        readonly=False,
-        description=_("Whether this craft recipe is private."),
+    private = exported(
+        Bool(
+            title=_("Private"),
+            required=False,
+            readonly=False,
+            description=_("Whether this craft recipe is private."),
+        )
+    )
+
+    can_upload_to_store = exported(
+        Bool(
+            title=_("Can upload to the CraftStore"),
+            required=True,
+            readonly=True,
+            description=_(
+                "Whether everything is set up to allow uploading builds of "
+                "this craftrecipe to the CraftStore."
+            ),
+        )
     )
 
     def getAllowedInformationTypes(user):
@@ -317,6 +365,22 @@ class ICraftRecipeView(Interface):
         :return: `ICraftRecipeBuild`.
         """
 
+    @call_with(requester=REQUEST_USER)
+    @operation_parameters(
+        channels=Dict(
+            title=_("Source snap channels to use for these builds."),
+            description=_(
+                "A dictionary mapping snap names to channels to use for "
+                "these builds. Currently only 'rockcraft', 'sourcecraft', "
+                "'core', 'core18', 'core20', and 'core22' keys are "
+                "supported."
+            ),
+            key_type=TextLine(),
+            required=False,
+        )
+    )
+    @export_factory_operation(ICraftRecipeBuildRequest, [])
+    @operation_for_version("devel")
     def requestBuilds(requester, channels=None, architectures=None):
         """Request that the craft recipe be built.
 
@@ -365,57 +429,80 @@ class ICraftRecipeView(Interface):
         :return: `ICraftRecipeBuildRequest`.
         """
 
-    pending_build_requests = CollectionField(
-        title=_("Pending build requests for this craft recipe."),
-        value_type=Reference(ICraftRecipeBuildRequest),
-        required=True,
-        readonly=True,
+    pending_build_requests = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("Pending build requests for this craft recipe."),
+                value_type=Reference(ICraftRecipeBuildRequest),
+                required=True,
+                readonly=True,
+            )
+        )
     )
 
-    failed_build_requests = CollectionField(
-        title=_("Failed build requests for this craft recipe."),
-        value_type=Reference(ICraftRecipeBuildRequest),
-        required=True,
-        readonly=True,
+    failed_build_requests = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("Failed build requests for this craft recipe."),
+                value_type=Reference(ICraftRecipeBuildRequest),
+                required=True,
+                readonly=True,
+            )
+        )
     )
 
-    builds = CollectionField(
-        title=_("All builds of this craft recipe."),
-        description=_(
-            "All builds of this craft recipe, sorted in descending order "
-            "of finishing (or starting if not completed successfully)."
-        ),
-        # Really ICraftRecipeBuild.
-        value_type=Reference(schema=Interface),
-        readonly=True,
+    builds = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("All builds of this craft recipe."),
+                description=_(
+                    "All builds of this craft recipe, sorted in descending "
+                    "order of finishing (or starting if not completed "
+                    "successfully)."
+                ),
+                # Really ICraftRecipeBuild.
+                value_type=Reference(schema=Interface),
+                readonly=True,
+            )
+        )
     )
 
-    completed_builds = CollectionField(
-        title=_("Completed builds of this craft recipe."),
-        description=_(
-            "Completed builds of this craft recipe, sorted in descending "
-            "order of finishing."
-        ),
-        # Really ICraftRecipeBuild.
-        value_type=Reference(schema=Interface),
-        readonly=True,
+    completed_builds = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("Completed builds of this craft recipe."),
+                description=_(
+                    "Completed builds of this craft recipe, sorted in "
+                    "descending order of finishing."
+                ),
+                # Really ICraftRecipeBuild.
+                value_type=Reference(schema=Interface),
+                readonly=True,
+            )
+        )
     )
 
-    pending_builds = CollectionField(
-        title=_("Pending builds of this craft recipe."),
-        description=_(
-            "Pending builds of this craft recipe, sorted in descending "
-            "order of creation."
-        ),
-        # Really ICraftRecipeBuild.
-        value_type=Reference(schema=Interface),
-        readonly=True,
+    pending_builds = exported(
+        doNotSnapshot(
+            CollectionField(
+                title=_("Pending builds of this craft recipe."),
+                description=_(
+                    "Pending builds of this craft recipe, sorted in "
+                    "descending order of creation."
+                ),
+                # Really ICraftRecipeBuild.
+                value_type=Reference(schema=Interface),
+                readonly=True,
+            )
+        )
     )
 
 
 class ICraftRecipeEdit(Interface):
     """`ICraftRecipe` methods that require launchpad.Edit permission."""
 
+    @export_destructor_operation()
+    @operation_for_version("devel")
     def destroySelf():
         """Delete this craft recipe, provided that it has no builds."""
 
@@ -436,27 +523,33 @@ class ICraftRecipeEditableAttributes(Interface):
         )
     )
 
-    project = ReferenceChoice(
-        title=_("The project that this craft recipe is associated with"),
-        schema=IProduct,
-        vocabulary="Product",
-        required=True,
-        readonly=False,
+    project = exported(
+        ReferenceChoice(
+            title=_("The project that this craft recipe is associated with"),
+            schema=IProduct,
+            vocabulary="Product",
+            required=True,
+            readonly=False,
+        )
     )
 
-    name = TextLine(
-        title=_("Craft recipe name"),
-        required=True,
-        readonly=False,
-        constraint=name_validator,
-        description=_("The name of the craft recipe."),
+    name = exported(
+        TextLine(
+            title=_("Craft recipe name"),
+            required=True,
+            readonly=False,
+            constraint=name_validator,
+            description=_("The name of the craft recipe."),
+        )
     )
 
-    description = Text(
-        title=_("Description"),
-        required=False,
-        readonly=False,
-        description=_("A description of the craft recipe."),
+    description = exported(
+        Text(
+            title=_("Description"),
+            required=False,
+            readonly=False,
+            description=_("A description of the craft recipe."),
+        )
     )
 
     git_repository = ReferenceChoice(
@@ -479,75 +572,93 @@ class ICraftRecipeEditableAttributes(Interface):
         ),
     )
 
-    git_ref = Reference(
-        IGitRef,
-        title=_("Git branch"),
-        required=False,
-        readonly=False,
-        description=_("The Git branch containing a craft.yaml recipe."),
+    git_ref = exported(
+        Reference(
+            IGitRef,
+            title=_("Git branch"),
+            required=False,
+            readonly=False,
+            description=_("The Git branch containing a craft.yaml recipe."),
+        )
     )
 
-    build_path = TextLine(
-        title=_("Build path"),
-        description=_("Subdirectory within the branch containing craft.yaml."),
-        constraint=path_does_not_escape,
-        required=False,
-        readonly=False,
+    build_path = exported(
+        TextLine(
+            title=_("Build path"),
+            description=_(
+                "Subdirectory within the branch containing craft.yaml."
+            ),
+            constraint=path_does_not_escape,
+            required=False,
+            readonly=False,
+        )
     )
-    information_type = Choice(
-        title=_("Information type"),
-        vocabulary=InformationType,
-        required=True,
-        readonly=False,
-        default=InformationType.PUBLIC,
-        description=_(
-            "The type of information contained in this craft recipe."
-        ),
+    information_type = exported(
+        Choice(
+            title=_("Information type"),
+            vocabulary=InformationType,
+            required=True,
+            readonly=False,
+            default=InformationType.PUBLIC,
+            description=_(
+                "The type of information contained in this craft recipe."
+            ),
+        )
     )
 
-    auto_build = Bool(
-        title=_("Automatically build when branch changes"),
-        required=True,
-        readonly=False,
-        description=_(
-            "Whether this craft recipe is built automatically when the branch "
-            "containing its craft.yaml recipe changes."
-        ),
+    auto_build = exported(
+        Bool(
+            title=_("Automatically build when branch changes"),
+            required=True,
+            readonly=False,
+            description=_(
+                "Whether this craft recipe is built automatically when the "
+                "branch containing its craft.yaml recipe changes."
+            ),
+        )
     )
 
-    auto_build_channels = Dict(
-        title=_("Source snap channels for automatic builds"),
-        key_type=TextLine(),
-        required=False,
-        readonly=False,
-        description=_(
-            "A dictionary mapping snap names to channels to use when building "
-            "this craft recipe.  Currently only 'core', 'core18', 'core20', "
-            "and 'craft' keys are supported."
-        ),
+    auto_build_channels = exported(
+        Dict(
+            title=_("Source snap channels for automatic builds"),
+            key_type=TextLine(),
+            required=False,
+            readonly=False,
+            description=_(
+                "A dictionary mapping snap names to channels to use when "
+                "building this craft recipe.  Currently only 'core', "
+                "'core18', 'core20', and 'craft' keys are supported."
+            ),
+        )
     )
 
-    is_stale = Bool(
-        title=_("Craft recipe is stale and is due to be rebuilt."),
-        required=True,
-        readonly=True,
+    is_stale = exported(
+        Bool(
+            title=_("Craft recipe is stale and is due to be rebuilt."),
+            required=True,
+            readonly=True,
+        )
     )
 
-    store_upload = Bool(
-        title=_("Automatically upload to store"),
-        required=True,
-        readonly=False,
-        description=_(
-            "Whether builds of this craft recipe are automatically uploaded "
-            "to the store."
-        ),
+    store_upload = exported(
+        Bool(
+            title=_("Automatically upload to store"),
+            required=True,
+            readonly=False,
+            description=_(
+                "Whether builds of this craft recipe are automatically "
+                "uploaded to the store."
+            ),
+        )
     )
 
-    store_name = TextLine(
-        title=_("Registered store name"),
-        required=False,
-        readonly=False,
-        description=_("The registered name of this craft in the store."),
+    store_name = exported(
+        TextLine(
+            title=_("Registered store name"),
+            required=False,
+            readonly=False,
+            description=_("The registered name of this craft in the store."),
+        )
     )
 
     store_secrets = List(
@@ -561,18 +672,20 @@ class ICraftRecipeEditableAttributes(Interface):
         ),
     )
 
-    store_channels = List(
-        title=_("Store channels"),
-        required=False,
-        readonly=False,
-        constraint=channels_validator,
-        description=_(
-            "Channels to release this craft to after uploading it to the "
-            "store. A channel is defined by a combination of an optional "
-            "track, a risk, and an optional branch, e.g. "
-            "'2.1/stable/fix-123', '2.1/stable', 'stable/fix-123', or "
-            "'stable'."
-        ),
+    store_channels = exported(
+        List(
+            title=_("Store channels"),
+            required=False,
+            readonly=False,
+            constraint=channels_validator,
+            description=_(
+                "Channels to release this craft to after uploading it to the "
+                "store. A channel is defined by a combination of an optional "
+                "track, a risk, and an optional branch, e.g. "
+                "'2.1/stable/fix-123', '2.1/stable', 'stable/fix-123', or "
+                "'stable'."
+            ),
+        )
     )
 
 
@@ -582,14 +695,21 @@ class ICraftRecipeAdminAttributes(Interface):
     These attributes need launchpad.View to see, and launchpad.Admin to change.
     """
 
-    require_virtualized = Bool(
-        title=_("Require virtualized builders"),
-        required=True,
-        readonly=False,
-        description=_("Only build this craft recipe on virtual builders."),
+    require_virtualized = exported(
+        Bool(
+            title=_("Require virtualized builders"),
+            required=True,
+            readonly=False,
+            description=_("Only build this craft recipe on virtual builders."),
+        )
     )
 
 
+# XXX ruinedyourlife 2024-10-02
+# https://bugs.launchpad.net/lazr.restful/+bug/760849:
+# "beta" is a lie to get WADL generation working.
+# Individual attributes must set their version to "devel".
+@exported_as_webservice_entry(as_of="beta")
 class ICraftRecipe(
     ICraftRecipeView,
     ICraftRecipeEdit,
@@ -600,9 +720,37 @@ class ICraftRecipe(
     """A buildable craft recipe."""
 
 
+# XXX ruinedyourlife 2024-10-02
+# https://bugs.launchpad.net/lazr.restful/+bug/760849:
+# "beta" is a lie to get WADL generation working.
+# Individual attributes must set their version to "devel".
+@exported_as_webservice_collection(ICraftRecipe)
 class ICraftRecipeSet(Interface):
     """A utility to create and access craft recipes."""
 
+    @call_with(registrant=REQUEST_USER)
+    @operation_parameters(
+        information_type=copy_field(
+            ICraftRecipe["information_type"], required=False
+        )
+    )
+    @export_factory_operation(
+        ICraftRecipe,
+        [
+            "owner",
+            "project",
+            "name",
+            "description",
+            "git_ref",
+            "build_path",
+            "auto_build",
+            "auto_build_channels",
+            "store_upload",
+            "store_name",
+            "store_channels",
+        ],
+    )
+    @operation_for_version("devel")
     def new(
         registrant,
         owner,
@@ -623,6 +771,14 @@ class ICraftRecipeSet(Interface):
     ):
         """Create an `ICraftRecipe`."""
 
+    @operation_parameters(
+        owner=Reference(IPerson, title=_("Owner"), required=True),
+        project=Reference(IProduct, title=_("Project"), required=True),
+        name=TextLine(title=_("Recipe name"), required=True),
+    )
+    @operation_returns_entry(ICraftRecipe)
+    @export_read_operation()
+    @operation_for_version("devel")
     def getByName(owner, project, name):
         """Returns the appropriate `ICraftRecipe` for the given objects."""
 
@@ -651,6 +807,13 @@ class ICraftRecipeSet(Interface):
         will have no source and so cannot dispatch new builds.
         """
 
+    @collection_default_content()
+    def empty_list():
+        """Return an empty collection of craft recipes.
+
+        This only exists to keep lazr.restful happy.
+        """
+
     def preloadDataForRecipes(recipes, user):
         """Load the data related to a list of craft recipes."""
 
diff --git a/lib/lp/crafts/interfaces/craftrecipebuild.py b/lib/lp/crafts/interfaces/craftrecipebuild.py
index e99e294..4c77eb8 100644
--- a/lib/lp/crafts/interfaces/craftrecipebuild.py
+++ b/lib/lp/crafts/interfaces/craftrecipebuild.py
@@ -9,6 +9,7 @@ __all__ = [
     "ICraftRecipeBuildSet",
 ]
 
+from lazr.restful.declarations import exported, exported_as_webservice_entry
 from lazr.restful.fields import Reference
 from zope.interface import Attribute, Interface
 from zope.schema import Bool, Datetime, Dict, Int, TextLine
@@ -42,45 +43,59 @@ class ICraftRecipeBuildView(IPackageBuildView):
         readonly=True,
     )
 
-    requester = Reference(
-        IPerson,
-        title=_("The person who requested this build."),
-        required=True,
-        readonly=True,
+    requester = exported(
+        Reference(
+            IPerson,
+            title=_("The person who requested this build."),
+            required=True,
+            readonly=True,
+        )
     )
 
-    recipe = Reference(
-        ICraftRecipe,
-        title=_("The craft recipe to build."),
-        required=True,
-        readonly=True,
+    recipe = exported(
+        Reference(
+            ICraftRecipe,
+            title=_("The craft recipe to build."),
+            required=True,
+            readonly=True,
+        )
     )
 
-    distro_arch_series = Reference(
-        IDistroArchSeries,
-        title=_("The series and architecture for which to build."),
-        required=True,
-        readonly=True,
+    distro_arch_series = exported(
+        Reference(
+            IDistroArchSeries,
+            title=_("The series and architecture for which to build."),
+            required=True,
+            readonly=True,
+        )
     )
 
-    channels = Dict(
-        title=_("Source snap channels to use for this build."),
-        description=_(
-            "A dictionary mapping snap names to channels to use for this "
-            "build.  Currently only 'core', 'core18', 'core20', "
-            "and 'sourcecraft' keys are supported."
-        ),
-        key_type=TextLine(),
+    arch_tag = exported(
+        TextLine(title=_("Architecture tag"), required=True, readonly=True)
+    )
+
+    channels = exported(
+        Dict(
+            title=_("Source snap channels to use for this build."),
+            description=_(
+                "A dictionary mapping snap names to channels to use for this "
+                "build.  Currently only 'core', 'core18', 'core20', "
+                "and 'sourcecraft' keys are supported."
+            ),
+            key_type=TextLine(),
+        )
     )
 
     virtualized = Bool(
         title=_("If True, this build is virtualized."), readonly=True
     )
 
-    score = Int(
-        title=_("Score of the related build farm job (if any)."),
-        required=False,
-        readonly=True,
+    score = exported(
+        Int(
+            title=_("Score of the related build farm job (if any)."),
+            required=False,
+            readonly=True,
+        )
     )
 
     eta = Datetime(
@@ -99,14 +114,16 @@ class ICraftRecipeBuildView(IPackageBuildView):
         readonly=True,
     )
 
-    revision_id = TextLine(
-        title=_("Revision ID"),
-        required=False,
-        readonly=True,
-        description=_(
-            "The revision ID of the branch used for this build, if "
-            "available."
-        ),
+    revision_id = exported(
+        TextLine(
+            title=_("Revision ID"),
+            required=False,
+            readonly=True,
+            description=_(
+                "The revision ID of the branch used for this build, if "
+                "available."
+            ),
+        )
     )
 
     store_upload_metadata = Attribute(
@@ -155,6 +172,11 @@ class ICraftRecipeBuildAdmin(Interface):
         """Change the build's score."""
 
 
+# XXX ruinedyourlife 2024-10-02
+# beta" is a lie to get WADL generation working,
+# see https://bugs.launchpad.net/lazr.restful/+bug/760849
+# Individual attributes must set their version to "devel".
+@exported_as_webservice_entry(as_of="beta")
 class ICraftRecipeBuild(
     ICraftRecipeBuildView,
     ICraftRecipeBuildEdit,
diff --git a/lib/lp/crafts/interfaces/webservice.py b/lib/lp/crafts/interfaces/webservice.py
new file mode 100644
index 0000000..ad511cb
--- /dev/null
+++ b/lib/lp/crafts/interfaces/webservice.py
@@ -0,0 +1,44 @@
+# Copyright 2024 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""All the interfaces that are exposed through the webservice.
+
+There is a declaration in ZCML somewhere that looks like:
+  <webservice:register module="lp.crafts.interfaces.webservice" />
+
+which tells `lazr.restful` that it should look for webservice exports here.
+"""
+
+__all__ = [
+    "ICraftRecipe",
+    "ICraftRecipeBuild",
+    "ICraftRecipeBuildRequest",
+    "ICraftRecipeSet",
+]
+
+from lp.crafts.interfaces.craftrecipe import (
+    ICraftRecipe,
+    ICraftRecipeBuildRequest,
+    ICraftRecipeSet,
+    ICraftRecipeView,
+)
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuild
+from lp.services.webservice.apihelpers import (
+    patch_collection_property,
+    patch_reference_property,
+)
+
+# ICraftRecipeBuildRequest
+patch_reference_property(ICraftRecipeBuildRequest, "recipe", ICraftRecipe)
+patch_collection_property(
+    ICraftRecipeBuildRequest, "builds", ICraftRecipeBuild
+)
+
+# ICraftRecipeView
+patch_collection_property(ICraftRecipeView, "builds", ICraftRecipeBuild)
+patch_collection_property(
+    ICraftRecipeView, "completed_builds", ICraftRecipeBuild
+)
+patch_collection_property(
+    ICraftRecipeView, "pending_builds", ICraftRecipeBuild
+)
diff --git a/lib/lp/crafts/model/craftrecipe.py b/lib/lp/crafts/model/craftrecipe.py
index cf4670c..cd2203c 100644
--- a/lib/lp/crafts/model/craftrecipe.py
+++ b/lib/lp/crafts/model/craftrecipe.py
@@ -367,6 +367,12 @@ class CraftRecipe(StormBase):
             if self._isBuildableArchitectureAllowed(das)
         ]
 
+    @property
+    def can_upload_to_store(self):
+        # no store upload planned for the initial implementation, as artifacts
+        # get pulled from Launchpad for now only.
+        return False
+
     def destroySelf(self):
         """See `ICraftRecipe`."""
         store = IStore(self)
@@ -899,6 +905,10 @@ class CraftRecipeSet:
             git_repository_id=None, git_path=None, date_last_modified=UTC_NOW
         )
 
+    def empty_list(self):
+        """See `ICraftRecipeSet`."""
+        return []
+
     def getSourcecraftYaml(self, context, logger=None):
         """See `ICraftRecipeSet`."""
         if ICraftRecipe.providedBy(context):
diff --git a/lib/lp/crafts/model/craftrecipebuild.py b/lib/lp/crafts/model/craftrecipebuild.py
index e859c6b..aeaf5e7 100644
--- a/lib/lp/crafts/model/craftrecipebuild.py
+++ b/lib/lp/crafts/model/craftrecipebuild.py
@@ -180,6 +180,11 @@ class CraftRecipeBuild(PackageBuildMixin, StormBase):
         return self.distro_arch_series.distroseries
 
     @property
+    def arch_tag(self):
+        """See `ICraftRecipeBuild`."""
+        return self.distro_arch_series.architecturetag
+
+    @property
     def archive(self):
         """See `IPackageBuild`."""
         return self.distribution.main_archive
diff --git a/lib/lp/crafts/tests/test_craftrecipe.py b/lib/lp/crafts/tests/test_craftrecipe.py
index cbff62a..eac12e9 100644
--- a/lib/lp/crafts/tests/test_craftrecipe.py
+++ b/lib/lp/crafts/tests/test_craftrecipe.py
@@ -3,13 +3,21 @@
 
 """Test craft recipes."""
 
+import json
+from datetime import timedelta
 from textwrap import dedent
 
+import iso8601
 import transaction
 from storm.locals import Store
 from testtools.matchers import (
+    AfterPreprocessing,
+    ContainsDict,
     Equals,
+    GreaterThan,
     Is,
+    LessThan,
+    MatchesAll,
     MatchesDict,
     MatchesSetwise,
     MatchesStructure,
@@ -19,7 +27,11 @@ from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-from lp.buildmaster.enums import BuildQueueStatus, BuildStatus
+from lp.buildmaster.enums import (
+    BuildBaseImageType,
+    BuildQueueStatus,
+    BuildStatus,
+)
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.processor import (
     IProcessorSet,
@@ -30,6 +42,7 @@ from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.tests.helpers import GitHostingFixture
 from lp.crafts.interfaces.craftrecipe import (
     CRAFT_RECIPE_ALLOW_CREATE,
+    CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
     BadCraftRecipeSearchContext,
     CraftRecipeBuildAlreadyPending,
     CraftRecipeBuildDisallowedArchitecture,
@@ -38,6 +51,7 @@ from lp.crafts.interfaces.craftrecipe import (
     CraftRecipePrivateFeatureDisabled,
     ICraftRecipe,
     ICraftRecipeSet,
+    ICraftRecipeView,
     NoSourceForCraftRecipe,
 )
 from lp.crafts.interfaces.craftrecipebuild import (
@@ -49,6 +63,7 @@ from lp.crafts.interfaces.craftrecipejob import (
 )
 from lp.crafts.model.craftrecipebuild import CraftFile
 from lp.crafts.model.craftrecipejob import CraftRecipeJob
+from lp.registry.enums import PersonVisibility, TeamMembershipPolicy
 from lp.services.config import config
 from lp.services.database.constants import ONE_DAY_AGO, UTC_NOW
 from lp.services.database.interfaces import IStore
@@ -59,14 +74,26 @@ from lp.services.database.sqlbase import (
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.runner import JobRunner
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.services.webapp.snapshot import notify_modified
-from lp.testing import TestCaseWithFactory, admin_logged_in, person_logged_in
+from lp.testing import (
+    ANONYMOUS,
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    admin_logged_in,
+    api_url,
+    login,
+    logout,
+    person_logged_in,
+)
 from lp.testing.dbuser import dbuser
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadFunctionalLayer,
     LaunchpadZopelessLayer,
 )
+from lp.testing.matchers import DoesNotSnapshot, HasQueryCount
+from lp.testing.pages import webservice_for_person
 
 
 class TestCraftRecipeFeatureFlags(TestCaseWithFactory):
@@ -113,6 +140,21 @@ class TestCraftRecipe(TestCaseWithFactory):
             repr(recipe),
         )
 
+    def test_avoids_problematic_snapshots(self):
+        self.assertThat(
+            self.factory.makeCraftRecipe(),
+            DoesNotSnapshot(
+                [
+                    "pending_build_requests",
+                    "failed_build_requests",
+                    "builds",
+                    "completed_builds",
+                    "pending_builds",
+                ],
+                ICraftRecipeView,
+            ),
+        )
+
     def test_initial_date_last_modified(self):
         # The initial value of date_last_modified is date_created.
         recipe = self.factory.makeCraftRecipe(date_created=ONE_DAY_AGO)
@@ -900,3 +942,668 @@ class TestCraftRecipeDeleteWithBuilds(TestCaseWithFactory):
         self.assertIsNotNone(
             store.get(CraftFile, removeSecurityProxy(other_craft_file).id)
         )
+
+
+class TestCraftRecipeWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(
+            FeatureFixture(
+                {
+                    CRAFT_RECIPE_ALLOW_CREATE: "on",
+                    CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+                }
+            )
+        )
+        self.person = self.factory.makePerson(displayname="Test Person")
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PUBLIC
+        )
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def makeCraftRecipe(
+        self,
+        owner=None,
+        project=None,
+        name=None,
+        git_ref=None,
+        private=False,
+        webservice=None,
+        **kwargs,
+    ):
+        if owner is None:
+            owner = self.person
+        if project is None:
+            project = self.factory.makeProduct(owner=owner)
+        if name is None:
+            name = self.factory.getUniqueUnicode()
+        if git_ref is None:
+            [git_ref] = self.factory.makeGitRefs()
+        if webservice is None:
+            webservice = self.webservice
+        transaction.commit()
+        owner_url = api_url(owner)
+        project_url = api_url(project)
+        git_ref_url = api_url(git_ref)
+        logout()
+        information_type = (
+            InformationType.PROPRIETARY if private else InformationType.PUBLIC
+        )
+        response = webservice.named_post(
+            "/+craft-recipes",
+            "new",
+            owner=owner_url,
+            project=project_url,
+            name=name,
+            git_ref=git_ref_url,
+            information_type=information_type.title,
+            **kwargs,
+        )
+        self.assertEqual(201, response.status)
+        return webservice.get(response.getHeader("Location")).jsonBody()
+
+    def getCollectionLinks(self, entry, member):
+        """Return a list of self_link attributes of entries in a collection."""
+        collection = self.webservice.get(
+            entry["%s_collection_link" % member]
+        ).jsonBody()
+        return [entry["self_link"] for entry in collection["entries"]]
+
+    def test_new_git(self):
+        # Craft recipe creation based on a Git branch works.
+        team = self.factory.makeTeam(
+            owner=self.person,
+            membership_policy=TeamMembershipPolicy.RESTRICTED,
+        )
+        project = self.factory.makeProduct(owner=team)
+        [ref] = self.factory.makeGitRefs()
+        recipe = self.makeCraftRecipe(
+            owner=team, project=project, name="test-craft", git_ref=ref
+        )
+        with person_logged_in(self.person):
+            self.assertThat(
+                recipe,
+                ContainsDict(
+                    {
+                        "registrant_link": Equals(self.getURL(self.person)),
+                        "owner_link": Equals(self.getURL(team)),
+                        "project_link": Equals(self.getURL(project)),
+                        "name": Equals("test-craft"),
+                        "git_ref_link": Equals(self.getURL(ref)),
+                        "build_path": Is(None),
+                        "require_virtualized": Is(True),
+                    }
+                ),
+            )
+
+    def test_new_store_options(self):
+        # The store-related options in CraftRecipe.new work.
+        store_name = self.factory.getUniqueUnicode()
+        recipe = self.makeCraftRecipe(
+            store_upload=True, store_name=store_name, store_channels=["edge"]
+        )
+        with person_logged_in(self.person):
+            self.assertThat(
+                recipe,
+                ContainsDict(
+                    {
+                        "store_upload": Is(True),
+                        "store_name": Equals(store_name),
+                        "store_channels": Equals(["edge"]),
+                    }
+                ),
+            )
+
+    def test_duplicate(self):
+        # An attempt to create a duplicate craft recipe fails.
+        team = self.factory.makeTeam(
+            owner=self.person,
+            membership_policy=TeamMembershipPolicy.RESTRICTED,
+        )
+        project = self.factory.makeProduct(owner=team)
+        name = self.factory.getUniqueUnicode()
+        [git_ref] = self.factory.makeGitRefs()
+        owner_url = api_url(team)
+        project_url = api_url(project)
+        git_ref_url = api_url(git_ref)
+        self.makeCraftRecipe(
+            owner=team, project=project, name=name, git_ref=git_ref
+        )
+        response = self.webservice.named_post(
+            "/+craft-recipes",
+            "new",
+            owner=owner_url,
+            project=project_url,
+            name=name,
+            git_ref=git_ref_url,
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=400,
+                body=(
+                    b"There is already a craft recipe with the same project, "
+                    b"owner, and name."
+                ),
+            ),
+        )
+
+    def test_not_owner(self):
+        # If the registrant is not the owner or a member of the owner team,
+        # craft recipe creation fails.
+        other_person = self.factory.makePerson(displayname="Other Person")
+        other_team = self.factory.makeTeam(
+            owner=other_person, displayname="Other Team"
+        )
+        project = self.factory.makeProduct(owner=self.person)
+        [git_ref] = self.factory.makeGitRefs()
+        transaction.commit()
+        other_person_url = api_url(other_person)
+        other_team_url = api_url(other_team)
+        project_url = api_url(project)
+        git_ref_url = api_url(git_ref)
+        logout()
+        response = self.webservice.named_post(
+            "/+craft-recipes",
+            "new",
+            owner=other_person_url,
+            project=project_url,
+            name="test-craft",
+            git_ref=git_ref_url,
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=401,
+                body=(
+                    b"Test Person cannot create craft recipes owned by "
+                    b"Other Person."
+                ),
+            ),
+        )
+        response = self.webservice.named_post(
+            "/+craft-recipes",
+            "new",
+            owner=other_team_url,
+            project=project_url,
+            name="test-craft",
+            git_ref=git_ref_url,
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=401, body=b"Test Person is not a member of Other Team."
+            ),
+        )
+
+    def test_cannot_set_private_components_of_public_recipe(self):
+        # If a craft recipe is public, then trying to change its owner or
+        # git_ref components to be private fails.
+        recipe = self.factory.makeCraftRecipe(
+            registrant=self.person,
+            owner=self.person,
+            git_ref=self.factory.makeGitRefs()[0],
+        )
+        private_team = self.factory.makeTeam(
+            owner=self.person, visibility=PersonVisibility.PRIVATE
+        )
+        [private_ref] = self.factory.makeGitRefs(
+            owner=self.person, information_type=InformationType.PRIVATESECURITY
+        )
+        recipe_url = api_url(recipe)
+        with person_logged_in(self.person):
+            private_team_url = api_url(private_team)
+            private_ref_url = api_url(private_ref)
+        logout()
+        private_webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PRIVATE
+        )
+        private_webservice.default_api_version = "devel"
+        response = private_webservice.patch(
+            recipe_url,
+            "application/json",
+            json.dumps({"owner_link": private_team_url}),
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=400,
+                body=b"A public craft recipe cannot have a private owner.",
+            ),
+        )
+        response = private_webservice.patch(
+            recipe_url,
+            "application/json",
+            json.dumps({"git_ref_link": private_ref_url}),
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=400,
+                body=b"A public craft recipe cannot have a private "
+                b"repository.",
+            ),
+        )
+
+    def test_is_stale(self):
+        # is_stale is exported and is read-only.
+        recipe = self.makeCraftRecipe()
+        self.assertTrue(recipe["is_stale"])
+        response = self.webservice.patch(
+            recipe["self_link"],
+            "application/json",
+            json.dumps({"is_stale": False}),
+        )
+        self.assertEqual(400, response.status)
+
+    def test_getByName(self):
+        # lp.craft_recipes.getByName returns a matching CraftRecipe.
+        project = self.factory.makeProduct(owner=self.person)
+        name = self.factory.getUniqueUnicode()
+        recipe = self.makeCraftRecipe(project=project, name=name)
+        with person_logged_in(self.person):
+            owner_url = api_url(self.person)
+            project_url = api_url(project)
+        response = self.webservice.named_get(
+            "/+craft-recipes",
+            "getByName",
+            owner=owner_url,
+            project=project_url,
+            name=name,
+        )
+        self.assertEqual(200, response.status)
+        self.assertEqual(recipe, response.jsonBody())
+
+    def test_getByName_missing(self):
+        # lp.craft_recipes.getByName returns 404 for a non-existent
+        # CraftRecipe.
+        project = self.factory.makeProduct(owner=self.person)
+        logout()
+        with person_logged_in(self.person):
+            owner_url = api_url(self.person)
+            project_url = api_url(project)
+        response = self.webservice.named_get(
+            "/+craft-recipes",
+            "getByName",
+            owner=owner_url,
+            project=project_url,
+            name="nonexistent",
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=404,
+                body=(
+                    b"No such craft recipe with this owner and project: "
+                    b"'nonexistent'."
+                ),
+            ),
+        )
+
+    def makeBuildableDistroArchSeries(
+        self,
+        distroseries=None,
+        architecturetag=None,
+        processor=None,
+        supports_virtualized=True,
+        supports_nonvirtualized=True,
+        **kwargs,
+    ):
+        if architecturetag is None:
+            architecturetag = self.factory.getUniqueUnicode("arch")
+        if processor is None:
+            try:
+                processor = getUtility(IProcessorSet).getByName(
+                    architecturetag
+                )
+            except ProcessorNotFound:
+                processor = self.factory.makeProcessor(
+                    name=architecturetag,
+                    supports_virtualized=supports_virtualized,
+                    supports_nonvirtualized=supports_nonvirtualized,
+                )
+        das = self.factory.makeDistroArchSeries(
+            distroseries=distroseries,
+            architecturetag=architecturetag,
+            processor=processor,
+            **kwargs,
+        )
+        # Add both a chroot and a LXD image to test that
+        # getAllowedArchitectures doesn't get confused by multiple
+        # PocketChroot rows for a single DistroArchSeries.
+        fake_chroot = self.factory.makeLibraryFileAlias(
+            filename="fake_chroot.tar.gz", db_only=True
+        )
+        das.addOrUpdateChroot(fake_chroot)
+        fake_lxd = self.factory.makeLibraryFileAlias(
+            filename="fake_lxd.tar.gz", db_only=True
+        )
+        das.addOrUpdateChroot(fake_lxd, image_type=BuildBaseImageType.LXD)
+        return das
+
+    def test_requestBuilds(self):
+        # Requests for builds for all relevant architectures can be
+        # performed over the webservice, and the returned entry indicates
+        # the status of the asynchronous job.
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            registrant=self.person,
+        )
+        processors = [
+            self.factory.makeProcessor(supports_virtualized=True)
+            for _ in range(3)
+        ]
+        for processor in processors:
+            self.makeBuildableDistroArchSeries(
+                distroseries=distroseries,
+                architecturetag=processor.name,
+                processor=processor,
+                owner=self.person,
+            )
+        [git_ref] = self.factory.makeGitRefs()
+        recipe = self.makeCraftRecipe(git_ref=git_ref)
+        now = get_transaction_timestamp(IStore(distroseries))
+        response = self.webservice.named_post(
+            recipe["self_link"],
+            "requestBuilds",
+            channels={"sourcecraft": "edge"},
+        )
+        self.assertEqual(201, response.status)
+        build_request_url = response.getHeader("Location")
+        build_request = self.webservice.get(build_request_url).jsonBody()
+        self.assertThat(
+            build_request,
+            ContainsDict(
+                {
+                    "date_requested": AfterPreprocessing(
+                        iso8601.parse_date, GreaterThan(now)
+                    ),
+                    "date_finished": Is(None),
+                    "recipe_link": Equals(recipe["self_link"]),
+                    "status": Equals("Pending"),
+                    "error_message": Is(None),
+                    "builds_collection_link": Equals(
+                        build_request_url + "/builds"
+                    ),
+                }
+            ),
+        )
+        self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+        with person_logged_in(self.person):
+            sourcecraft_yaml = f"""
+                name: ruff
+                license: MIT
+                version: 0.4.9
+                summary: An extremely fast Python linter, written in Rust.
+                description: Ruff aims to be orders of magnitude faster...
+                base: ubuntu@{distroseries.version}
+                platforms:
+                """
+            for processor in processors:
+                sourcecraft_yaml += f"""
+                    {processor.name}:
+                        build-on: [{processor.name}]
+                        build-for: {processor.name}
+                    """
+            self.useFixture(GitHostingFixture(blob=sourcecraft_yaml))
+            [job] = getUtility(ICraftRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+                JobRunner([job]).runAll()
+        date_requested = iso8601.parse_date(build_request["date_requested"])
+        now = get_transaction_timestamp(IStore(distroseries))
+        build_request = self.webservice.get(
+            build_request["self_link"]
+        ).jsonBody()
+        self.assertThat(
+            build_request,
+            ContainsDict(
+                {
+                    "date_requested": AfterPreprocessing(
+                        iso8601.parse_date, Equals(date_requested)
+                    ),
+                    "date_finished": AfterPreprocessing(
+                        iso8601.parse_date,
+                        MatchesAll(GreaterThan(date_requested), LessThan(now)),
+                    ),
+                    "recipe_link": Equals(recipe["self_link"]),
+                    "status": Equals("Completed"),
+                    "error_message": Is(None),
+                    "builds_collection_link": Equals(
+                        build_request_url + "/builds"
+                    ),
+                }
+            ),
+        )
+        builds = self.webservice.get(
+            build_request["builds_collection_link"]
+        ).jsonBody()["entries"]
+        with person_logged_in(self.person):
+            self.assertThat(
+                builds,
+                MatchesSetwise(
+                    *(
+                        ContainsDict(
+                            {
+                                "recipe_link": Equals(recipe["self_link"]),
+                                "archive_link": Equals(
+                                    self.getURL(distroseries.main_archive)
+                                ),
+                                "arch_tag": Equals(processor.name),
+                                "channels": Equals({"sourcecraft": "edge"}),
+                            }
+                        )
+                        for processor in processors
+                    )
+                ),
+            )
+
+    def test_requestBuilds_failure(self):
+        # If the asynchronous build request job fails, this is reflected in
+        # the build request entry.
+        [git_ref] = self.factory.makeGitRefs()
+        recipe = self.makeCraftRecipe(git_ref=git_ref)
+        now = get_transaction_timestamp(IStore(git_ref))
+        response = self.webservice.named_post(
+            recipe["self_link"], "requestBuilds"
+        )
+        self.assertEqual(201, response.status)
+        build_request_url = response.getHeader("Location")
+        build_request = self.webservice.get(build_request_url).jsonBody()
+        self.assertThat(
+            build_request,
+            ContainsDict(
+                {
+                    "date_requested": AfterPreprocessing(
+                        iso8601.parse_date, GreaterThan(now)
+                    ),
+                    "date_finished": Is(None),
+                    "recipe_link": Equals(recipe["self_link"]),
+                    "status": Equals("Pending"),
+                    "error_message": Is(None),
+                    "builds_collection_link": Equals(
+                        build_request_url + "/builds"
+                    ),
+                }
+            ),
+        )
+        self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+        with person_logged_in(self.person):
+            self.useFixture(GitHostingFixture()).getBlob.failure = Exception(
+                "Something went wrong"
+            )
+            [job] = getUtility(ICraftRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+                JobRunner([job]).runAll()
+        date_requested = iso8601.parse_date(build_request["date_requested"])
+        now = get_transaction_timestamp(IStore(git_ref))
+        build_request = self.webservice.get(
+            build_request["self_link"]
+        ).jsonBody()
+        self.assertThat(
+            build_request,
+            ContainsDict(
+                {
+                    "date_requested": AfterPreprocessing(
+                        iso8601.parse_date, Equals(date_requested)
+                    ),
+                    "date_finished": AfterPreprocessing(
+                        iso8601.parse_date,
+                        MatchesAll(GreaterThan(date_requested), LessThan(now)),
+                    ),
+                    "recipe_link": Equals(recipe["self_link"]),
+                    "status": Equals("Failed"),
+                    "error_message": Equals("Something went wrong"),
+                    "builds_collection_link": Equals(
+                        build_request_url + "/builds"
+                    ),
+                }
+            ),
+        )
+        self.assertEqual([], self.getCollectionLinks(build_request, "builds"))
+
+    def test_requestBuilds_not_owner(self):
+        # If the requester is not the owner or a member of the owner team,
+        # build requests are rejected.
+        other_team = self.factory.makeTeam(
+            displayname="Other Team",
+            membership_policy=TeamMembershipPolicy.RESTRICTED,
+        )
+        other_webservice = webservice_for_person(
+            other_team.teamowner, permission=OAuthPermission.WRITE_PUBLIC
+        )
+        other_webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+        recipe = self.makeCraftRecipe(
+            owner=other_team, webservice=other_webservice
+        )
+        response = self.webservice.named_post(
+            recipe["self_link"], "requestBuilds"
+        )
+        self.assertThat(
+            response,
+            MatchesStructure.byEquality(
+                status=401,
+                body=(
+                    b"Test Person cannot create craft recipe builds owned by "
+                    b"Other Team."
+                ),
+            ),
+        )
+
+    def test_getBuilds(self):
+        # The builds, completed_builds, and pending_builds properties are as
+        # expected.
+        project = self.factory.makeProduct(owner=self.person)
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            registrant=self.person,
+        )
+        processors = [
+            self.factory.makeProcessor(supports_virtualized=True)
+            for _ in range(4)
+        ]
+        for processor in processors:
+            self.makeBuildableDistroArchSeries(
+                distroseries=distroseries,
+                architecturetag=processor.name,
+                processor=processor,
+                owner=self.person,
+            )
+        recipe = self.makeCraftRecipe(project=project)
+        response = self.webservice.named_post(
+            recipe["self_link"], "requestBuilds"
+        )
+        self.assertEqual(201, response.status)
+        with person_logged_in(self.person):
+            sourcecraft_yaml = f"""
+                name: ruff
+                license: MIT
+                version: 0.4.9
+                summary: An extremely fast Python linter, written in Rust.
+                description: Ruff aims to be orders of magnitude faster...
+                base: ubuntu@{distroseries.version}
+                platforms:
+                """
+            for processor in processors:
+                sourcecraft_yaml += f"""
+                    {processor.name}:
+                        build-on: [{processor.name}]
+                        build-for: {processor.name}
+                    """
+            self.useFixture(GitHostingFixture(blob=sourcecraft_yaml))
+            [job] = getUtility(ICraftRecipeRequestBuildsJobSource).iterReady()
+            with dbuser(config.ICraftRecipeRequestBuildsJobSource.dbuser):
+                JobRunner([job]).runAll()
+        builds = self.getCollectionLinks(recipe, "builds")
+        self.assertEqual(len(processors), len(builds))
+        self.assertEqual(
+            [], self.getCollectionLinks(recipe, "completed_builds")
+        )
+        self.assertEqual(
+            builds, self.getCollectionLinks(recipe, "pending_builds")
+        )
+
+        with person_logged_in(self.person):
+            db_recipe = getUtility(ICraftRecipeSet).getByName(
+                self.person, project, recipe["name"]
+            )
+            db_builds = list(db_recipe.builds)
+            db_builds[0].updateStatus(
+                BuildStatus.BUILDING, date_started=db_recipe.date_created
+            )
+            db_builds[0].updateStatus(
+                BuildStatus.FULLYBUILT,
+                date_finished=db_recipe.date_created + timedelta(minutes=10),
+            )
+        # Builds that have not yet been started are listed last.  This does
+        # mean that pending builds that have never been started are sorted
+        # to the end, but means that builds that were cancelled before
+        # starting don't pollute the start of the collection forever.
+        self.assertEqual(builds, self.getCollectionLinks(recipe, "builds"))
+        self.assertEqual(
+            builds[:1], self.getCollectionLinks(recipe, "completed_builds")
+        )
+        self.assertEqual(
+            builds[1:], self.getCollectionLinks(recipe, "pending_builds")
+        )
+
+        with person_logged_in(self.person):
+            db_builds[1].updateStatus(
+                BuildStatus.BUILDING, date_started=db_recipe.date_created
+            )
+            db_builds[1].updateStatus(
+                BuildStatus.FULLYBUILT,
+                date_finished=db_recipe.date_created + timedelta(minutes=20),
+            )
+        self.assertEqual(
+            [builds[1], builds[0], builds[2], builds[3]],
+            self.getCollectionLinks(recipe, "builds"),
+        )
+        self.assertEqual(
+            [builds[1], builds[0]],
+            self.getCollectionLinks(recipe, "completed_builds"),
+        )
+        self.assertEqual(
+            builds[2:], self.getCollectionLinks(recipe, "pending_builds")
+        )
+
+    def test_query_count(self):
+        # CraftRecipe has a reasonable query count.
+        recipe = self.factory.makeCraftRecipe(
+            registrant=self.person, owner=self.person
+        )
+        url = api_url(recipe)
+        logout()
+        store = Store.of(recipe)
+        store.flush()
+        store.invalidate()
+        with StormStatementRecorder() as recorder:
+            self.webservice.get(url)
+        self.assertThat(recorder, HasQueryCount(Equals(19)))
diff --git a/lib/lp/crafts/tests/test_craftrecipebuild.py b/lib/lp/crafts/tests/test_craftrecipebuild.py
index f7f5129..5821ae3 100644
--- a/lib/lp/crafts/tests/test_craftrecipebuild.py
+++ b/lib/lp/crafts/tests/test_craftrecipebuild.py
@@ -4,14 +4,16 @@
 """Test craft package build features."""
 
 from datetime import datetime, timedelta, timezone
+from urllib.request import urlopen
 
 import six
-from testtools.matchers import Equals
+from testtools.matchers import ContainsDict, Equals, Is
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
 from lp.app.errors import NotFoundError
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
@@ -29,14 +31,20 @@ from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
 from lp.services.propertycache import clear_property_cache
+from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
+    ANONYMOUS,
     StormStatementRecorder,
     TestCaseWithFactory,
+    api_url,
+    login,
+    logout,
     person_logged_in,
 )
-from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.layers import LaunchpadFunctionalLayer, LaunchpadZopelessLayer
 from lp.testing.mail_helpers import pop_notifications
 from lp.testing.matchers import HasQueryCount
+from lp.testing.pages import webservice_for_person
 
 expected_body = """\
  * Craft Recipe: craft-1
@@ -452,3 +460,139 @@ class TestCraftRecipeBuildSet(TestCaseWithFactory):
             recipe=recipe, distro_arch_series=distro_arch_series
         )
         self.assertFalse(target.virtualized)
+
+
+class TestCraftRecipeBuildWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(
+            FeatureFixture(
+                {
+                    CRAFT_RECIPE_ALLOW_CREATE: "on",
+                    CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+                }
+            )
+        )
+        self.person = self.factory.makePerson()
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PRIVATE
+        )
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def test_properties(self):
+        # The basic properties of a craft recipe build are sensible.
+        db_build = self.factory.makeCraftRecipeBuild(
+            requester=self.person,
+            date_created=datetime(2021, 9, 15, 16, 21, 0, tzinfo=timezone.utc),
+        )
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        with person_logged_in(self.person):
+            self.assertThat(
+                build,
+                ContainsDict(
+                    {
+                        "requester_link": Equals(self.getURL(self.person)),
+                        "recipe_link": Equals(self.getURL(db_build.recipe)),
+                        "distro_arch_series_link": Equals(
+                            self.getURL(db_build.distro_arch_series)
+                        ),
+                        "arch_tag": Equals(
+                            db_build.distro_arch_series.architecturetag
+                        ),
+                        "channels": Is(None),
+                        "score": Is(None),
+                        "can_be_rescored": Is(False),
+                        "can_be_retried": Is(False),
+                        "can_be_cancelled": Is(False),
+                    }
+                ),
+            )
+
+    def test_public(self):
+        # A craft recipe build with a public recipe is itself public.
+        db_build = self.factory.makeCraftRecipeBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC
+        )
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        self.assertEqual(200, unpriv_webservice.get(build_url).status)
+
+    def test_cancel(self):
+        # The owner of a build can cancel it.
+        db_build = self.factory.makeCraftRecipeBuild(requester=self.person)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC
+        )
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertTrue(build["can_be_cancelled"])
+        response = unpriv_webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(401, response.status)
+        response = self.webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertFalse(build["can_be_cancelled"])
+        with person_logged_in(self.person):
+            self.assertEqual(BuildStatus.CANCELLED, db_build.status)
+
+    def test_rescore(self):
+        # Buildd administrators can rescore builds.
+        db_build = self.factory.makeCraftRecipeBuild(requester=self.person)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin]
+        )
+        buildd_admin_webservice = webservice_for_person(
+            buildd_admin, permission=OAuthPermission.WRITE_PUBLIC
+        )
+        buildd_admin_webservice.default_api_version = "devel"
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(2510, build["score"])
+        self.assertTrue(build["can_be_rescored"])
+        response = self.webservice.named_post(
+            build["self_link"], "rescore", score=5000
+        )
+        self.assertEqual(401, response.status)
+        response = buildd_admin_webservice.named_post(
+            build["self_link"], "rescore", score=5000
+        )
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(5000, build["score"])
+
+    def assertCanOpenRedirectedUrl(self, browser, url):
+        browser.open(url)
+        self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+        urlopen(browser.headers["Location"]).close()
+
+    def test_logs(self):
+        # API clients can fetch the build and upload logs.
+        db_build = self.factory.makeCraftRecipeBuild(requester=self.person)
+        db_build.setLog(self.factory.makeLibraryFileAlias("buildlog.txt.gz"))
+        db_build.storeUploadLog("uploaded")
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        browser = self.getNonRedirectingBrowser(user=self.person)
+        browser.raiseHttpErrors = False
+        self.assertIsNotNone(build["build_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["build_log_url"])
+        self.assertIsNotNone(build["upload_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["upload_log_url"])