Skip to content

Commit 338568d

Browse files
authored
Merge pull request #21 from PostHog/vdekrijger-add-project-resource
feat(resource): Add project resource
2 parents efb1f73 + 0d83fcc commit 338568d

File tree

5 files changed

+580
-0
lines changed

5 files changed

+580
-0
lines changed

docs/resources/project.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "posthog_project Resource - posthog"
4+
subcategory: ""
5+
description: |-
6+
Manages a PostHog project within an organization.
7+
---
8+
9+
# posthog_project (Resource)
10+
11+
Manages a PostHog project within an organization.
12+
13+
14+
15+
<!-- schema generated by tfplugindocs -->
16+
## Schema
17+
18+
### Required
19+
20+
- `name` (String) The name of the project.
21+
- `organization_id` (String) The identifier of the organization this project belongs to.
22+
23+
### Optional
24+
25+
- `timezone` (String) The timezone for this project (e.g., 'UTC', 'America/New_York', 'Europe/London'). Defaults to 'UTC'.
26+
27+
### Read-Only
28+
29+
- `api_token` (String, Sensitive) The API token for this project. This is used to send events to PostHog.
30+
- `id` (Number) The numeric identifier of the project.

internal/httpclient/project.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package httpclient
2+
3+
import (
4+
"context"
5+
"fmt"
6+
)
7+
8+
type Project struct {
9+
ID int64 `json:"id"`
10+
Name *string `json:"name,omitempty"`
11+
OrganizationID *string `json:"organization,omitempty"`
12+
APIToken *string `json:"api_token,omitempty"`
13+
Timezone *string `json:"timezone,omitempty"`
14+
}
15+
16+
type ProjectRequest struct {
17+
Name *string `json:"name,omitempty"`
18+
Timezone *string `json:"timezone,omitempty"`
19+
}
20+
21+
func (c *PosthogClient) CreateProject(ctx context.Context, organizationID string, input ProjectRequest) (Project, error) {
22+
path := fmt.Sprintf("/api/organizations/%s/projects/", organizationID)
23+
result, _, err := doPost[Project](c, ctx, path, input)
24+
return result, err
25+
}
26+
27+
func (c *PosthogClient) GetProject(ctx context.Context, organizationID, projectID string) (Project, HTTPStatusCode, error) {
28+
path := fmt.Sprintf("/api/organizations/%s/projects/%s/", organizationID, projectID)
29+
return doGet[Project](c, ctx, path)
30+
}
31+
32+
func (c *PosthogClient) UpdateProject(ctx context.Context, organizationID, projectID string, input ProjectRequest) (Project, HTTPStatusCode, error) {
33+
path := fmt.Sprintf("/api/organizations/%s/projects/%s/", organizationID, projectID)
34+
return doPatch[Project](c, ctx, path, input)
35+
}
36+
37+
func (c *PosthogClient) DeleteProject(ctx context.Context, organizationID, projectID string) (HTTPStatusCode, error) {
38+
path := fmt.Sprintf("/api/organizations/%s/projects/%s/", organizationID, projectID)
39+
return doDelete(c, ctx, path)
40+
}

internal/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ func (p *PostHogProvider) Resources(_ context.Context) []func() frameworkresourc
130130
posthogresource.NewFeatureFlag,
131131
posthogresource.NewHogFunction,
132132
posthogresource.NewInsight,
133+
posthogresource.NewProject,
133134
}
134135
}
135136

internal/resource/project.go

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
package resource
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/hashicorp/terraform-plugin-framework/resource"
10+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
11+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
12+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
13+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
14+
"github.com/hashicorp/terraform-plugin-framework/types"
15+
"github.com/hashicorp/terraform-plugin-log/tflog"
16+
"github.com/posthog/terraform-provider/internal/data"
17+
"github.com/posthog/terraform-provider/internal/httpclient"
18+
"github.com/posthog/terraform-provider/internal/resource/core"
19+
)
20+
21+
var (
22+
_ resource.Resource = &projectResource{}
23+
_ resource.ResourceWithConfigure = &projectResource{}
24+
_ resource.ResourceWithImportState = &projectResource{}
25+
)
26+
27+
func NewProject() resource.Resource {
28+
return &projectResource{}
29+
}
30+
31+
type projectResource struct {
32+
client httpclient.PosthogClient
33+
}
34+
35+
type ProjectTFModel struct {
36+
ID types.Int64 `tfsdk:"id"`
37+
Name types.String `tfsdk:"name"`
38+
OrganizationID types.String `tfsdk:"organization_id"`
39+
APIToken types.String `tfsdk:"api_token"`
40+
Timezone types.String `tfsdk:"timezone"`
41+
}
42+
43+
func (r *projectResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
44+
resp.TypeName = req.ProviderTypeName + "_project"
45+
}
46+
47+
func (r *projectResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
48+
resp.Schema = schema.Schema{
49+
MarkdownDescription: "Manages a PostHog project within an organization.",
50+
Attributes: map[string]schema.Attribute{
51+
"id": schema.Int64Attribute{
52+
Computed: true,
53+
MarkdownDescription: "The numeric identifier of the project.",
54+
PlanModifiers: []planmodifier.Int64{
55+
int64planmodifier.UseStateForUnknown(),
56+
},
57+
},
58+
"name": schema.StringAttribute{
59+
MarkdownDescription: "The name of the project.",
60+
Required: true,
61+
},
62+
"organization_id": schema.StringAttribute{
63+
Required: true,
64+
MarkdownDescription: "The identifier of the organization this project belongs to.",
65+
PlanModifiers: []planmodifier.String{
66+
stringplanmodifier.RequiresReplace(),
67+
},
68+
},
69+
"api_token": schema.StringAttribute{
70+
Computed: true,
71+
Sensitive: true,
72+
MarkdownDescription: "The API token for this project. This is used to send events to PostHog.",
73+
PlanModifiers: []planmodifier.String{
74+
stringplanmodifier.UseStateForUnknown(),
75+
},
76+
},
77+
"timezone": schema.StringAttribute{
78+
Optional: true,
79+
Computed: true,
80+
MarkdownDescription: "The timezone for this project (e.g., 'UTC', 'America/New_York', 'Europe/London'). Defaults to 'UTC'.",
81+
PlanModifiers: []planmodifier.String{
82+
stringplanmodifier.UseStateForUnknown(),
83+
},
84+
},
85+
},
86+
}
87+
}
88+
89+
func (r *projectResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
90+
if req.ProviderData == nil {
91+
return
92+
}
93+
94+
providerData, ok := req.ProviderData.(data.ProviderData)
95+
if !ok {
96+
resp.Diagnostics.AddError(
97+
"Unexpected Resource Configure Type",
98+
fmt.Sprintf("Expected ProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData),
99+
)
100+
return
101+
}
102+
103+
r.client = providerData.Client
104+
}
105+
106+
func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
107+
var plan ProjectTFModel
108+
109+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
110+
if resp.Diagnostics.HasError() {
111+
return
112+
}
113+
114+
organizationID := plan.OrganizationID.ValueString()
115+
name := plan.Name.ValueString()
116+
117+
tflog.Debug(ctx, "Creating PostHog Project", map[string]any{
118+
"organization_id": organizationID,
119+
"name": name,
120+
})
121+
122+
apiReq := httpclient.ProjectRequest{
123+
Name: &name,
124+
}
125+
126+
if !plan.Timezone.IsNull() && !plan.Timezone.IsUnknown() {
127+
tz := plan.Timezone.ValueString()
128+
apiReq.Timezone = &tz
129+
}
130+
131+
project, err := r.client.CreateProject(ctx, organizationID, apiReq)
132+
if err != nil {
133+
resp.Diagnostics.AddError("Error creating project", err.Error())
134+
return
135+
}
136+
137+
r.mapResponseToModel(&project, &plan)
138+
139+
tflog.Debug(ctx, "Created PostHog Project", map[string]any{
140+
"id": plan.ID.ValueInt64(),
141+
"organization_id": organizationID,
142+
})
143+
144+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
145+
}
146+
147+
func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
148+
var state ProjectTFModel
149+
150+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
151+
if resp.Diagnostics.HasError() {
152+
return
153+
}
154+
155+
if state.ID.IsNull() || state.ID.IsUnknown() {
156+
resp.State.RemoveResource(ctx)
157+
return
158+
}
159+
160+
organizationID := state.OrganizationID.ValueString()
161+
projectID := fmt.Sprintf("%d", state.ID.ValueInt64())
162+
163+
tflog.Debug(ctx, "Reading PostHog Project", map[string]any{
164+
"id": projectID,
165+
"organization_id": organizationID,
166+
})
167+
168+
project, statusCode, err := r.client.GetProject(ctx, organizationID, projectID)
169+
if err != nil {
170+
if statusCode == http.StatusNotFound {
171+
tflog.Warn(ctx, "Project not found, removing from state", map[string]any{"id": projectID})
172+
resp.State.RemoveResource(ctx)
173+
return
174+
}
175+
resp.Diagnostics.AddError("Error reading project", err.Error())
176+
return
177+
}
178+
179+
r.mapResponseToModel(&project, &state)
180+
181+
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
182+
}
183+
184+
func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
185+
var plan, state ProjectTFModel
186+
187+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
188+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
189+
if resp.Diagnostics.HasError() {
190+
return
191+
}
192+
193+
organizationID := state.OrganizationID.ValueString()
194+
projectID := fmt.Sprintf("%d", state.ID.ValueInt64())
195+
196+
tflog.Debug(ctx, "Updating PostHog Project", map[string]any{
197+
"id": projectID,
198+
"organization_id": organizationID,
199+
})
200+
201+
name := plan.Name.ValueString()
202+
apiReq := httpclient.ProjectRequest{
203+
Name: &name,
204+
}
205+
206+
if !plan.Timezone.IsNull() && !plan.Timezone.IsUnknown() {
207+
tz := plan.Timezone.ValueString()
208+
apiReq.Timezone = &tz
209+
}
210+
211+
project, statusCode, err := r.client.UpdateProject(ctx, organizationID, projectID, apiReq)
212+
if err != nil {
213+
if statusCode == http.StatusNotFound {
214+
tflog.Warn(ctx, "Project not found during update, removing from state", map[string]any{"id": projectID})
215+
resp.Diagnostics.AddWarning(
216+
"Resource not found",
217+
fmt.Sprintf("Project with ID %s was deleted externally and will be recreated.", projectID),
218+
)
219+
resp.State.RemoveResource(ctx)
220+
return
221+
}
222+
resp.Diagnostics.AddError("Error updating project", err.Error())
223+
return
224+
}
225+
226+
r.mapResponseToModel(&project, &plan)
227+
228+
tflog.Debug(ctx, "Updated PostHog Project", map[string]any{
229+
"id": plan.ID.ValueInt64(),
230+
"organization_id": organizationID,
231+
})
232+
233+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
234+
}
235+
236+
func (r *projectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
237+
var state ProjectTFModel
238+
239+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
240+
if resp.Diagnostics.HasError() {
241+
return
242+
}
243+
244+
if state.ID.IsNull() || state.ID.IsUnknown() {
245+
resp.Diagnostics.AddError("Invalid resource ID", "Resource ID is absent in state")
246+
return
247+
}
248+
249+
organizationID := state.OrganizationID.ValueString()
250+
projectID := fmt.Sprintf("%d", state.ID.ValueInt64())
251+
252+
tflog.Debug(ctx, "Deleting PostHog Project", map[string]any{
253+
"id": projectID,
254+
"organization_id": organizationID,
255+
})
256+
257+
statusCode, err := r.client.DeleteProject(ctx, organizationID, projectID)
258+
if err != nil {
259+
if statusCode == http.StatusNotFound {
260+
tflog.Warn(ctx, "Project already deleted, removing from state", map[string]any{"id": projectID})
261+
resp.State.RemoveResource(ctx)
262+
return
263+
}
264+
resp.Diagnostics.AddError("Error deleting project", err.Error())
265+
return
266+
}
267+
268+
tflog.Debug(ctx, "Deleted PostHog Project", map[string]any{
269+
"id": projectID,
270+
"organization_id": organizationID,
271+
})
272+
}
273+
274+
func (r *projectResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
275+
// Import format: organization_id:project_id
276+
parts := strings.Split(req.ID, ":")
277+
if len(parts) != 2 {
278+
resp.Diagnostics.AddError(
279+
"Invalid import ID",
280+
fmt.Sprintf("Import ID must be in format 'organization_id:project_id', got: %s", req.ID),
281+
)
282+
return
283+
}
284+
285+
organizationID := parts[0]
286+
projectID := parts[1]
287+
288+
tflog.Debug(ctx, "Importing PostHog Project", map[string]any{
289+
"organization_id": organizationID,
290+
"project_id": projectID,
291+
})
292+
293+
project, _, err := r.client.GetProject(ctx, organizationID, projectID)
294+
if err != nil {
295+
resp.Diagnostics.AddError("Error reading project during import", err.Error())
296+
return
297+
}
298+
299+
var state ProjectTFModel
300+
state.OrganizationID = types.StringValue(organizationID)
301+
r.mapResponseToModel(&project, &state)
302+
303+
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
304+
}
305+
306+
func (r *projectResource) mapResponseToModel(project *httpclient.Project, model *ProjectTFModel) {
307+
model.ID = types.Int64Value(project.ID)
308+
model.Name = core.PtrToStringNullIfEmptyTrimmed(project.Name)
309+
model.APIToken = core.PtrToStringNullIfEmptyTrimmed(project.APIToken)
310+
model.Timezone = core.PtrToStringNullIfEmptyTrimmed(project.Timezone)
311+
}

0 commit comments

Comments
 (0)