From 4c1baeef480793c3cb26a7365ff5e49be77ae67a Mon Sep 17 00:00:00 2001 From: Adam Jenkins Date: Thu, 12 Feb 2026 14:18:21 -0400 Subject: [PATCH 1/2] feat: add import project API endpoint Add POST /projects/{projectKey}/import endpoint to the dev server API that allows importing a project from JSON data. This provides HTTP API parity with the existing CLI import-project command. Changes: - Add /projects/{projectKey}/import endpoint to OpenAPI spec - Regenerate server.gen.go with new endpoint types - Implement PostImportProject handler that reuses model.ImportProject() - Validates required fields (sourceEnvironmentKey, flagsState) - Returns 409 Conflict if project already exists - Returns 400 Bad Request for missing fields - Returns 201 Created with project data on success The endpoint accepts the same JSON format as output from: ldcli dev-server get-project --project= \ --expand=overrides --expand=availableVariations This enables programmatic project imports and UI integration. Co-authored-by: Cursor --- internal/dev_server/api/api.yaml | 48 +++++++ .../dev_server/api/post_import_project.go | 82 +++++++++++ internal/dev_server/api/server.gen.go | 129 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 internal/dev_server/api/post_import_project.go diff --git a/internal/dev_server/api/api.yaml b/internal/dev_server/api/api.yaml index c2d9dcb8..7f3599fb 100644 --- a/internal/dev_server/api/api.yaml +++ b/internal/dev_server/api/api.yaml @@ -113,6 +113,54 @@ paths: $ref: "#/components/responses/ErrorResponse" 409: $ref: "#/components/responses/ErrorResponse" + /projects/{projectKey}/import: + post: + summary: Import a project from JSON data + operationId: postImportProject + parameters: + - $ref: "#/components/parameters/projectKey" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - sourceEnvironmentKey + - flagsState + - context + properties: + sourceEnvironmentKey: + type: string + description: environment to copy flag values from + context: + $ref: "#/components/schemas/Context" + flagsState: + type: object + description: flags and their values for the project + x-go-type: model.FlagsState + x-go-type-import: + path: github.com/launchdarkly/ldcli/internal/dev_server/model + overrides: + type: object + description: overridden flags for the project + x-go-type: model.FlagsState + x-go-type-import: + path: github.com/launchdarkly/ldcli/internal/dev_server/model + availableVariations: + type: object + description: available variations for each flag + additionalProperties: + type: array + items: + $ref: '#/components/schemas/Variation' + responses: + 201: + $ref: "#/components/responses/Project" + 400: + $ref: "#/components/responses/ErrorResponse" + 409: + $ref: "#/components/responses/ErrorResponse" /projects/{projectKey}/overrides: delete: summary: remove all overrides for the given project diff --git a/internal/dev_server/api/post_import_project.go b/internal/dev_server/api/post_import_project.go new file mode 100644 index 00000000..35570c2e --- /dev/null +++ b/internal/dev_server/api/post_import_project.go @@ -0,0 +1,82 @@ +package api + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/launchdarkly/ldcli/internal/dev_server/model" +) + +func (s server) PostImportProject(ctx context.Context, request PostImportProjectRequestObject) (PostImportProjectResponseObject, error) { + // Validate required fields + if request.Body.SourceEnvironmentKey == "" { + return PostImportProject400JSONResponse{ + ErrorResponseJSONResponse{ + Code: "invalid_request", + Message: "sourceEnvironmentKey is required", + }, + }, nil + } + + if len(request.Body.FlagsState) == 0 { + return PostImportProject400JSONResponse{ + ErrorResponseJSONResponse{ + Code: "invalid_request", + Message: "flagsState is required", + }, + }, nil + } + + // Build import data from request + importData := model.ImportData{ + Context: request.Body.Context, + SourceEnvironmentKey: request.Body.SourceEnvironmentKey, + FlagsState: request.Body.FlagsState, + Overrides: request.Body.Overrides, + } + + // Convert availableVariations if present + if request.Body.AvailableVariations != nil { + variations := make(map[string][]model.ImportVariation) + for flagKey, vars := range *request.Body.AvailableVariations { + for _, v := range vars { + variations[flagKey] = append(variations[flagKey], model.ImportVariation{ + Id: v.Id, + Name: v.Name, + Description: v.Description, + Value: v.Value, + }) + } + } + importData.AvailableVariations = &variations + } + + // Import the project + err := model.ImportProject(ctx, request.ProjectKey, importData) + switch { + case errors.As(err, &model.ErrAlreadyExists{}): + return PostImportProject409JSONResponse{ + Code: "conflict", + Message: err.Error(), + }, nil + case err != nil: + return nil, err + } + + // Fetch the imported project to return + store := model.StoreFromContext(ctx) + project, err := store.GetDevProject(ctx, request.ProjectKey) + if err != nil { + return nil, err + } + + response := ProjectJSONResponse{ + LastSyncedFromSource: project.LastSyncTime.Unix(), + Context: project.Context, + SourceEnvironmentKey: project.SourceEnvironmentKey, + FlagsState: &project.AllFlagsState, + } + + return PostImportProject201JSONResponse{response}, nil +} diff --git a/internal/dev_server/api/server.gen.go b/internal/dev_server/api/server.gen.go index 0f724947..8bd226b0 100644 --- a/internal/dev_server/api/server.gen.go +++ b/internal/dev_server/api/server.gen.go @@ -233,12 +233,33 @@ type GetEnvironmentsParams struct { Limit *int `form:"limit,omitempty" json:"limit,omitempty"` } +// PostImportProjectJSONBody defines parameters for PostImportProject. +type PostImportProjectJSONBody struct { + // AvailableVariations available variations for each flag + AvailableVariations *map[string][]Variation `json:"availableVariations,omitempty"` + + // Context context object to use when evaluating flags in source environment + Context Context `json:"context"` + + // FlagsState flags and their values for the project + FlagsState model.FlagsState `json:"flagsState"` + + // Overrides overridden flags for the project + Overrides *model.FlagsState `json:"overrides,omitempty"` + + // SourceEnvironmentKey environment to copy flag values from + SourceEnvironmentKey string `json:"sourceEnvironmentKey"` +} + // PatchProjectJSONRequestBody defines body for PatchProject for application/json ContentType. type PatchProjectJSONRequestBody PatchProjectJSONBody // PostAddProjectJSONRequestBody defines body for PostAddProject for application/json ContentType. type PostAddProjectJSONRequestBody PostAddProjectJSONBody +// PostImportProjectJSONRequestBody defines body for PostImportProject for application/json ContentType. +type PostImportProjectJSONRequestBody PostImportProjectJSONBody + // PutOverrideFlagJSONRequestBody defines body for PutOverrideFlag for application/json ContentType. type PutOverrideFlagJSONRequestBody = FlagValue @@ -277,6 +298,9 @@ type ServerInterface interface { // list all environments for the given project // (GET /projects/{projectKey}/environments) GetEnvironments(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, params GetEnvironmentsParams) + // Import a project from JSON data + // (POST /projects/{projectKey}/import) + PostImportProject(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) // remove all overrides for the given project // (DELETE /projects/{projectKey}/overrides) DeleteOverrides(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) @@ -628,6 +652,31 @@ func (siw *ServerInterfaceWrapper) GetEnvironments(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// PostImportProject operation middleware +func (siw *ServerInterfaceWrapper) PostImportProject(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "projectKey" ------------- + var projectKey ProjectKey + + err = runtime.BindStyledParameterWithOptions("simple", "projectKey", mux.Vars(r)["projectKey"], &projectKey, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectKey", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostImportProject(w, r, projectKey) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // DeleteOverrides operation middleware func (siw *ServerInterfaceWrapper) DeleteOverrides(w http.ResponseWriter, r *http.Request) { @@ -856,6 +905,8 @@ func HandlerWithOptions(si ServerInterface, options GorillaServerOptions) http.H r.HandleFunc(options.BaseURL+"/projects/{projectKey}/environments", wrapper.GetEnvironments).Methods("GET") + r.HandleFunc(options.BaseURL+"/projects/{projectKey}/import", wrapper.PostImportProject).Methods("POST") + r.HandleFunc(options.BaseURL+"/projects/{projectKey}/overrides", wrapper.DeleteOverrides).Methods("DELETE") r.HandleFunc(options.BaseURL+"/projects/{projectKey}/overrides/{flagKey}", wrapper.DeleteFlagOverride).Methods("DELETE") @@ -1202,6 +1253,48 @@ func (response GetEnvironments404JSONResponse) VisitGetEnvironmentsResponse(w ht return json.NewEncoder(w).Encode(response) } +type PostImportProjectRequestObject struct { + ProjectKey ProjectKey `json:"projectKey"` + Body *PostImportProjectJSONRequestBody +} + +type PostImportProjectResponseObject interface { + VisitPostImportProjectResponse(w http.ResponseWriter) error +} + +type PostImportProject201JSONResponse struct{ ProjectJSONResponse } + +func (response PostImportProject201JSONResponse) VisitPostImportProjectResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type PostImportProject400JSONResponse struct{ ErrorResponseJSONResponse } + +func (response PostImportProject400JSONResponse) VisitPostImportProjectResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type PostImportProject409JSONResponse struct { + // Code specific error code encountered + Code string `json:"code"` + + // Message description of the error + Message string `json:"message"` +} + +func (response PostImportProject409JSONResponse) VisitPostImportProjectResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + type DeleteOverridesRequestObject struct { ProjectKey ProjectKey `json:"projectKey"` } @@ -1315,6 +1408,9 @@ type StrictServerInterface interface { // list all environments for the given project // (GET /projects/{projectKey}/environments) GetEnvironments(ctx context.Context, request GetEnvironmentsRequestObject) (GetEnvironmentsResponseObject, error) + // Import a project from JSON data + // (POST /projects/{projectKey}/import) + PostImportProject(ctx context.Context, request PostImportProjectRequestObject) (PostImportProjectResponseObject, error) // remove all overrides for the given project // (DELETE /projects/{projectKey}/overrides) DeleteOverrides(ctx context.Context, request DeleteOverridesRequestObject) (DeleteOverridesResponseObject, error) @@ -1656,6 +1752,39 @@ func (sh *strictHandler) GetEnvironments(w http.ResponseWriter, r *http.Request, } } +// PostImportProject operation middleware +func (sh *strictHandler) PostImportProject(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) { + var request PostImportProjectRequestObject + + request.ProjectKey = projectKey + + var body PostImportProjectJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.PostImportProject(ctx, request.(PostImportProjectRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostImportProject") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(PostImportProjectResponseObject); ok { + if err := validResponse.VisitPostImportProjectResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // DeleteOverrides operation middleware func (sh *strictHandler) DeleteOverrides(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) { var request DeleteOverridesRequestObject From 03093b970f6abab3a925b5b14b5ed7cc29d1dd89 Mon Sep 17 00:00:00 2001 From: Adam Jenkins Date: Thu, 12 Feb 2026 14:27:13 -0400 Subject: [PATCH 2/2] fix: simplify import project API schema Use the Project schema for the import endpoint request body instead of defining a custom schema with explicit required fields. This makes it clear that the import endpoint accepts the same JSON format that the export (get-project) endpoint produces. Changes: - Update api.yaml to use Project schema ref for import request body - Regenerate server.gen.go with simplified type alias - Update handler to properly handle nullable FlagsState pointer - Add description clarifying the endpoint accepts exported JSON format This aligns with the CLI import-project command which accepts the exported JSON file directly without requiring individual field specification. Co-authored-by: Cursor --- internal/dev_server/api/api.yaml | 36 +++---------------- .../dev_server/api/post_import_project.go | 4 +-- internal/dev_server/api/server.gen.go | 24 ++----------- 3 files changed, 10 insertions(+), 54 deletions(-) diff --git a/internal/dev_server/api/api.yaml b/internal/dev_server/api/api.yaml index 7f3599fb..cc62585a 100644 --- a/internal/dev_server/api/api.yaml +++ b/internal/dev_server/api/api.yaml @@ -115,7 +115,10 @@ paths: $ref: "#/components/responses/ErrorResponse" /projects/{projectKey}/import: post: - summary: Import a project from JSON data + summary: Import a project from exported JSON data + description: | + Import a project using the JSON format from get-project with expand options. + Accepts the same format that is exported by the get-project endpoint. operationId: postImportProject parameters: - $ref: "#/components/parameters/projectKey" @@ -124,36 +127,7 @@ paths: content: application/json: schema: - type: object - required: - - sourceEnvironmentKey - - flagsState - - context - properties: - sourceEnvironmentKey: - type: string - description: environment to copy flag values from - context: - $ref: "#/components/schemas/Context" - flagsState: - type: object - description: flags and their values for the project - x-go-type: model.FlagsState - x-go-type-import: - path: github.com/launchdarkly/ldcli/internal/dev_server/model - overrides: - type: object - description: overridden flags for the project - x-go-type: model.FlagsState - x-go-type-import: - path: github.com/launchdarkly/ldcli/internal/dev_server/model - availableVariations: - type: object - description: available variations for each flag - additionalProperties: - type: array - items: - $ref: '#/components/schemas/Variation' + $ref: "#/components/schemas/Project" responses: 201: $ref: "#/components/responses/Project" diff --git a/internal/dev_server/api/post_import_project.go b/internal/dev_server/api/post_import_project.go index 35570c2e..37470bc5 100644 --- a/internal/dev_server/api/post_import_project.go +++ b/internal/dev_server/api/post_import_project.go @@ -19,7 +19,7 @@ func (s server) PostImportProject(ctx context.Context, request PostImportProject }, nil } - if len(request.Body.FlagsState) == 0 { + if request.Body.FlagsState == nil || len(*request.Body.FlagsState) == 0 { return PostImportProject400JSONResponse{ ErrorResponseJSONResponse{ Code: "invalid_request", @@ -32,7 +32,7 @@ func (s server) PostImportProject(ctx context.Context, request PostImportProject importData := model.ImportData{ Context: request.Body.Context, SourceEnvironmentKey: request.Body.SourceEnvironmentKey, - FlagsState: request.Body.FlagsState, + FlagsState: *request.Body.FlagsState, Overrides: request.Body.Overrides, } diff --git a/internal/dev_server/api/server.gen.go b/internal/dev_server/api/server.gen.go index 8bd226b0..02c26907 100644 --- a/internal/dev_server/api/server.gen.go +++ b/internal/dev_server/api/server.gen.go @@ -233,24 +233,6 @@ type GetEnvironmentsParams struct { Limit *int `form:"limit,omitempty" json:"limit,omitempty"` } -// PostImportProjectJSONBody defines parameters for PostImportProject. -type PostImportProjectJSONBody struct { - // AvailableVariations available variations for each flag - AvailableVariations *map[string][]Variation `json:"availableVariations,omitempty"` - - // Context context object to use when evaluating flags in source environment - Context Context `json:"context"` - - // FlagsState flags and their values for the project - FlagsState model.FlagsState `json:"flagsState"` - - // Overrides overridden flags for the project - Overrides *model.FlagsState `json:"overrides,omitempty"` - - // SourceEnvironmentKey environment to copy flag values from - SourceEnvironmentKey string `json:"sourceEnvironmentKey"` -} - // PatchProjectJSONRequestBody defines body for PatchProject for application/json ContentType. type PatchProjectJSONRequestBody PatchProjectJSONBody @@ -258,7 +240,7 @@ type PatchProjectJSONRequestBody PatchProjectJSONBody type PostAddProjectJSONRequestBody PostAddProjectJSONBody // PostImportProjectJSONRequestBody defines body for PostImportProject for application/json ContentType. -type PostImportProjectJSONRequestBody PostImportProjectJSONBody +type PostImportProjectJSONRequestBody = Project // PutOverrideFlagJSONRequestBody defines body for PutOverrideFlag for application/json ContentType. type PutOverrideFlagJSONRequestBody = FlagValue @@ -298,7 +280,7 @@ type ServerInterface interface { // list all environments for the given project // (GET /projects/{projectKey}/environments) GetEnvironments(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, params GetEnvironmentsParams) - // Import a project from JSON data + // Import a project from exported JSON data // (POST /projects/{projectKey}/import) PostImportProject(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) // remove all overrides for the given project @@ -1408,7 +1390,7 @@ type StrictServerInterface interface { // list all environments for the given project // (GET /projects/{projectKey}/environments) GetEnvironments(ctx context.Context, request GetEnvironmentsRequestObject) (GetEnvironmentsResponseObject, error) - // Import a project from JSON data + // Import a project from exported JSON data // (POST /projects/{projectKey}/import) PostImportProject(ctx context.Context, request PostImportProjectRequestObject) (PostImportProjectResponseObject, error) // remove all overrides for the given project