Skip to content
Merged
7 changes: 7 additions & 0 deletions .github/workflows/php.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ jobs:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.1
Comment thread
zhumin8 marked this conversation as resolved.
with:
php-version: '8.2'
extensions: mbstring, xml, bcmath
- uses: ./.github/actions/setup-librarian
- name: Install dependencies
run: librarian install php
- name: Run tests
run: go run ./tool/cmd/coverage ./internal/librarian/php
9 changes: 9 additions & 0 deletions .yamlfmt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# The PHP generator (gapic-generator-php) has a preprocessor bug (fixYaml)
# that incorrectly indents top-level keys if they immediately follow indented blocks.
# The workaround requires keeping blank lines between top-level keys in
# secretmanager_v1.yaml, which the default yamlfmt settings would otherwise strip.
# We exclude the file here to prevent yamlfmt from formatting it.
# TODO(https://github.com/googleapis/gapic-generator-php/issues/837): determine to remove
# this workaround or adjust testdata to keep blank lines.
exclude:
- "internal/testdata/googleapis/google/cloud/secretmanager/v1/secretmanager_v1.yaml"
3 changes: 3 additions & 0 deletions internal/librarian/librarian.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/googleapis/librarian/internal/librarian/golang"
"github.com/googleapis/librarian/internal/librarian/java"
"github.com/googleapis/librarian/internal/librarian/nodejs"
"github.com/googleapis/librarian/internal/librarian/php"
"github.com/googleapis/librarian/internal/librarian/python"
"github.com/googleapis/librarian/internal/librarian/rust"
"github.com/googleapis/librarian/internal/protoc"
Expand Down Expand Up @@ -118,6 +119,8 @@ Examples:
return java.Install(ctx, tools)
case config.LanguageNodejs:
return nodejs.Install(ctx, tools)
case config.LanguagePhp:
return php.Install(ctx, tools)
case config.LanguagePython:
return python.Install(ctx)
case config.LanguageRust:
Expand Down
182 changes: 180 additions & 2 deletions internal/librarian/php/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,191 @@ package php

import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"

"github.com/googleapis/librarian/internal/command"
"github.com/googleapis/librarian/internal/config"
"github.com/googleapis/librarian/internal/filesystem"
"github.com/googleapis/librarian/internal/serviceconfig"
"github.com/googleapis/librarian/internal/sources"
)

// Generate generates a PHP client library.
func Generate(ctx context.Context, cfg *config.Config, library *config.Library, src *sources.Sources) error {
// TODO(https://github.com/googleapis/librarian/issues/6629): implement PHP generation
func Generate(ctx context.Context, cfg *config.Config, library *config.Library, src *sources.Sources) (err error) {
if len(library.APIs) == 0 {
return fmt.Errorf("no apis configured for library %q", library.Name)
}
protocPath, err := exec.LookPath("protoc")
if err != nil {
return fmt.Errorf("failed to find protoc: %w", err)
}

// Locate PHP generator
generatorDir, err := generatorDir(ctx)
if err != nil {
return fmt.Errorf("failed to locate PHP generator: %w", err)
}
wrapperPath := filepath.Join(generatorDir, "wrapper.sh")
if _, err := os.Stat(wrapperPath); err != nil {
return fmt.Errorf("PHP generator wrapper not found (did you run 'librarian install'?): %w", err)
}

// Setup sandbox staging dir
tempDir, err := os.MkdirTemp("", "librarian-php-")
if err != nil {
return err
}
defer func() {
if cleanupErr := os.RemoveAll(tempDir); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
}()

outputZipPath := filepath.Join(tempDir, "output.zip")
srcCfg := sources.NewSourceConfig(src, library.Roots)
for _, api := range library.APIs {
params := &generateAPIParams{
api: api,
library: library,
srcCfg: srcCfg,
wrapperPath: wrapperPath,
outputZipPath: outputZipPath,
protocPath: protocPath,
}
if err := generateAPI(ctx, params); err != nil {
return err
}
// Cleanup output zip for subsequent APIs in the same library package
_ = os.Remove(outputZipPath)
}

return nil
}

type generateAPIParams struct {
api *config.API
library *config.Library
srcCfg *sources.SourceConfig
wrapperPath string
outputZipPath string
protocPath string
}

// generateAPI generates a single target API by resolving its service config, gathering
// all target proto files, and executing the PHP generator plugin via protoc.
// It extracts the resulting ZIP archive directly to the library output directory.
func generateAPI(ctx context.Context, params *generateAPIParams) error {
googleapisDir := params.srcCfg.Root("googleapis")
// Resolve service config files
grpcConfigPath, err := serviceconfig.FindGRPCServiceConfig(googleapisDir, params.api.Path)
if err != nil {
return err
}
apiMetadata, err := serviceconfig.Find(googleapisDir, params.api.Path, config.LanguagePhp)
if err != nil {
return err
}
opts := gapicOpts(params.api, apiMetadata, grpcConfigPath)

// Gather target protos
var targetProtos []string
apiDir := filepath.Join(googleapisDir, params.api.Path)
protos, err := gatherProtos(apiDir)
if err != nil {
return err
}
targetProtos = append(targetProtos, protos...)
// Always include common resources if present
commonResources := filepath.Join(googleapisDir, "google/cloud/common_resources.proto")
if _, err := os.Stat(commonResources); err == nil {
targetProtos = append(targetProtos, commonResources)
}
if len(targetProtos) == 0 {
return fmt.Errorf("no target protos found for API %s", params.api.Path)
}
// Build protoc command arguments
gapicOutArg := fmt.Sprintf("--gapic_out=%s:%s", strings.Join(opts, ","), params.outputZipPath)
protocArgs := []string{
"--experimental_allow_proto3_optional",
"--plugin=protoc-gen-gapic=" + params.wrapperPath,
gapicOutArg,
}

// Append active root directories as include paths (-I) to resolve proto imports.
for _, root := range params.srcCfg.ActiveRoots {
if r := params.srcCfg.Root(root); r != "" {
protocArgs = append(protocArgs, "-I", r)
}
}
protocArgs = append(protocArgs, targetProtos...)
// Run compilation
if err := command.RunWithEnv(ctx, map[string]string{"GOOGLEAPIS_DIR": googleapisDir}, params.protocPath, protocArgs...); err != nil {
Comment thread
zhumin8 marked this conversation as resolved.
return fmt.Errorf("failed to generate PHP API %s: %w", params.api.Path, err)
}

// Extract output
outDir := params.library.Output
if err := os.MkdirAll(outDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory %s: %w", outDir, err)
}
if err := filesystem.Unzip(ctx, params.outputZipPath, outDir); err != nil {
return fmt.Errorf("failed to extract generated output to %s: %w", outDir, err)
}

return nil
}

// gatherProtos walks the directory tree recursively from root and returns
// a sorted list of absolute paths for all found proto files.
func gatherProtos(root string) ([]string, error) {
var protos []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if d.Type().IsRegular() && filepath.Ext(path) == ".proto" {
protos = append(protos, path)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(protos)
return protos, nil
}

func gapicOpts(api *config.API, apiMetadata *serviceconfig.API, grpcConfigPath string) []string {
transport := serviceconfig.GRPCRest
if apiMetadata != nil {
transport = apiMetadata.Transport(config.LanguagePhp)
}
opts := []string{"metadata", "transport=" + string(transport)}
migrationMode := "NEW_SURFACE_ONLY"
if api.PHP != nil && api.PHP.MigrationMode != "" {
migrationMode = api.PHP.MigrationMode
}
opts = append(opts, "migration-mode="+migrationMode)
if apiMetadata != nil && apiMetadata.HasRESTNumericEnums(config.LanguagePhp) {
opts = append(opts, "rest-numeric-enums")
}
opts = append(opts, "generate-snippets")

if grpcConfigPath != "" {
opts = append(opts, "grpc_service_config="+grpcConfigPath)
}
if apiMetadata != nil && apiMetadata.ServiceConfig != "" {
opts = append(opts, "service_yaml="+apiMetadata.ServiceConfig)
}

return opts
}
Loading
Loading